From 0b3c802a546c0a28d1f928a5396e59b781d2d1aa Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 20 Jan 2026 20:14:14 +0000 Subject: [PATCH 01/11] fix: memory leak issue (#9543) Co-authored-by: Josh McKinney --- codex-rs/core/src/exec.rs | 88 ++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 2a91873238..275f2fb856 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -47,6 +47,12 @@ const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; // conventional timeout exit code const READ_CHUNK_SIZE: usize = 8192; // bytes per read const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB +/// Hard cap on bytes retained from exec stdout/stderr/aggregated output. +/// +/// This mirrors unified exec's output cap so a single runaway command cannot +/// OOM the process by dumping huge amounts of data to stdout/stderr. +const EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB + /// Limit the number of ExecCommandOutputDelta events emitted per exec call. /// Aggregation still collects full output; only the live event stream is capped. pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; @@ -290,18 +296,32 @@ async fn exec_windows_sandbox( }; let exit_status = synthetic_exit_status(capture.exit_code); + let mut stdout_text = capture.stdout; + if stdout_text.len() > EXEC_OUTPUT_MAX_BYTES { + stdout_text.truncate(EXEC_OUTPUT_MAX_BYTES); + } + let mut stderr_text = capture.stderr; + if stderr_text.len() > EXEC_OUTPUT_MAX_BYTES { + stderr_text.truncate(EXEC_OUTPUT_MAX_BYTES); + } let stdout = StreamOutput { - text: capture.stdout, + text: stdout_text, truncated_after_lines: None, }; let stderr = StreamOutput { - text: capture.stderr, + text: stderr_text, truncated_after_lines: None, }; - // Best-effort aggregate: stdout then stderr - let mut aggregated = Vec::with_capacity(stdout.text.len() + stderr.text.len()); - append_all(&mut aggregated, &stdout.text); - append_all(&mut aggregated, &stderr.text); + // Best-effort aggregate: stdout then stderr (capped). + let mut aggregated = Vec::with_capacity( + stdout + .text + .len() + .saturating_add(stderr.text.len()) + .min(EXEC_OUTPUT_MAX_BYTES), + ); + append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES); + append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES); let aggregated_output = StreamOutput { text: aggregated, truncated_after_lines: None, @@ -490,8 +510,13 @@ impl StreamOutput> { } #[inline] -fn append_all(dst: &mut Vec, src: &[u8]) { - dst.extend_from_slice(src); +fn append_capped(dst: &mut Vec, src: &[u8], max_bytes: usize) { + if dst.len() >= max_bytes { + return; + } + let remaining = max_bytes.saturating_sub(dst.len()); + let take = remaining.min(src.len()); + dst.extend_from_slice(&src[..take]); } #[derive(Clone, Debug)] @@ -584,19 +609,15 @@ async fn consume_truncated_output( )) })?; - let (agg_tx, agg_rx) = async_channel::unbounded::>(); - let stdout_handle = tokio::spawn(read_capped( BufReader::new(stdout_reader), stdout_stream.clone(), false, - Some(agg_tx.clone()), )); let stderr_handle = tokio::spawn(read_capped( BufReader::new(stderr_reader), stdout_stream.clone(), true, - Some(agg_tx.clone()), )); let (exit_status, timed_out) = tokio::select! { @@ -662,15 +683,18 @@ async fn consume_truncated_output( Duration::from_millis(IO_DRAIN_TIMEOUT_MS), ) .await?; - - drop(agg_tx); - - let mut combined_buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY); - while let Ok(chunk) = agg_rx.recv().await { - append_all(&mut combined_buf, &chunk); - } + // Best-effort aggregate: stdout then stderr (capped). + let mut aggregated = Vec::with_capacity( + stdout + .text + .len() + .saturating_add(stderr.text.len()) + .min(EXEC_OUTPUT_MAX_BYTES), + ); + append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES); + append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES * 2); let aggregated_output = StreamOutput { - text: combined_buf, + text: aggregated, truncated_after_lines: None, }; @@ -687,14 +711,11 @@ async fn read_capped( mut reader: R, stream: Option, is_stderr: bool, - aggregate_tx: Option>>, ) -> io::Result>> { - let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY); + let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY.min(EXEC_OUTPUT_MAX_BYTES)); let mut tmp = [0u8; READ_CHUNK_SIZE]; let mut emitted_deltas: usize = 0; - // No caps: append all bytes - loop { let n = reader.read(&mut tmp).await?; if n == 0 { @@ -723,11 +744,7 @@ async fn read_capped( emitted_deltas += 1; } - if let Some(tx) = &aggregate_tx { - let _ = tx.send(tmp[..n].to_vec()).await; - } - - append_all(&mut buf, &tmp[..n]); + append_capped(&mut buf, &tmp[..n], EXEC_OUTPUT_MAX_BYTES); // Continue reading to EOF to avoid back-pressure } @@ -755,6 +772,7 @@ fn synthetic_exit_status(code: i32) -> ExitStatus { mod tests { use super::*; use std::time::Duration; + use tokio::io::AsyncWriteExt; fn make_exec_output( exit_code: i32, @@ -816,6 +834,18 @@ mod tests { )); } + #[tokio::test] + async fn read_capped_limits_retained_bytes() { + let (mut writer, reader) = tokio::io::duplex(1024); + let bytes = vec![b'a'; EXEC_OUTPUT_MAX_BYTES.saturating_add(128 * 1024)]; + tokio::spawn(async move { + writer.write_all(&bytes).await.expect("write"); + }); + + let out = read_capped(reader, None, false).await.expect("read"); + assert_eq!(out.text.len(), EXEC_OUTPUT_MAX_BYTES); + } + #[cfg(unix)] #[test] fn sandbox_detection_flags_sigsys_exit_code() { From 79c5bf9835a98f527cc6708504708f228fc3d755 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 20 Jan 2026 12:18:00 -0800 Subject: [PATCH 02/11] Fixed config merging issue with profiles (#9509) This PR fixes a small issue with chained (layered) config.toml file merging. The old logic didn't properly handle profiles. In particular, if a lower-layer config overrides a profile defined in a higher-layer config, the override did not take effect. This prevents users from having project-specific profile overrides and contradicts the (soon-to-be) documented behavior of config merging. The change adds a unit test for this case. It also exposes a function from the config crate that is needed by the app server code paths to implement support for layered configs. --- codex-rs/core/src/config/mod.rs | 59 ++++++++++++++++++++++++++- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout/list.rs | 19 +++++++++ codex-rs/tui/src/lib.rs | 68 +++++++++++++++++++++++-------- codex-rs/tui2/src/lib.rs | 68 +++++++++++++++++++++++-------- 5 files changed, 179 insertions(+), 36 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 20b86e2bf3..7d1426e7a4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -399,6 +399,7 @@ pub struct ConfigBuilder { cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, + fallback_cwd: Option, } impl ConfigBuilder { @@ -422,21 +423,29 @@ impl ConfigBuilder { self } + pub fn fallback_cwd(mut self, fallback_cwd: Option) -> Self { + self.fallback_cwd = fallback_cwd; + self + } + pub async fn build(self) -> std::io::Result { let Self { codex_home, cli_overrides, harness_overrides, loader_overrides, + fallback_cwd, } = self; let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?; let cli_overrides = cli_overrides.unwrap_or_default(); - let harness_overrides = harness_overrides.unwrap_or_default(); + let mut harness_overrides = harness_overrides.unwrap_or_default(); let loader_overrides = loader_overrides.unwrap_or_default(); - let cwd = match harness_overrides.cwd.as_deref() { + let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref()); + let cwd = match cwd_override { Some(path) => AbsolutePathBuf::try_from(path)?, None => AbsolutePathBuf::current_dir()?, }; + harness_overrides.cwd = Some(cwd.to_path_buf()); let config_layer_stack = load_config_layers_state(&codex_home, Some(cwd), &cli_overrides, loader_overrides) .await?; @@ -2301,6 +2310,52 @@ trust_level = "trusted" Ok(()) } + #[tokio::test] + async fn project_profile_overrides_user_profile() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +profile = "global" + +[profiles.global] +model = "gpt-global" + +[profiles.project] +model = "gpt-project" + +[projects."{workspace_key}"] +trust_level = "trusted" +"#, + ), + )?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join(CONFIG_TOML_FILE), + r#" +profile = "project" +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(workspace.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + + assert_eq!(config.active_profile.as_deref(), Some("project")); + assert_eq!(config.model.as_deref(), Some("gpt-project")); + + Ok(()) + } + #[test] fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index a23ae55aa8..e481f642a3 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -104,6 +104,7 @@ pub use rollout::list::ThreadSortKey; pub use rollout::list::ThreadsPage; pub use rollout::list::parse_cursor; pub use rollout::list::read_head_for_summary; +pub use rollout::list::read_session_meta_line; mod function_tool; mod state; mod tasks; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 3e53882fcf..01e7b1733d 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -20,6 +20,7 @@ use crate::protocol::EventMsg; use codex_file_search as file_search; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; /// Returned page of thread (thread) summaries. @@ -744,6 +745,24 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result io::Result { + let head = read_head_for_summary(path).await?; + let Some(first) = head.first() else { + return Err(io::Error::other(format!( + "rollout at {} is empty", + path.display() + ))); + }; + serde_json::from_value::(first.clone()).map_err(|_| { + io::Error::other(format!( + "rollout at {} does not start with session metadata", + path.display() + )) + }) +} + async fn file_modified_time(path: &Path) -> io::Result> { let meta = tokio::fs::metadata(path).await?; let modified = meta.modified().ok(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e57497505a..f5c2bc1e75 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -18,6 +18,7 @@ use codex_core::RolloutRecorder; use codex_core::ThreadSortKey; use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; @@ -25,6 +26,7 @@ use codex_core::config::resolve_oss_provider; use codex_core::find_thread_path_by_id_str; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; +use codex_core::read_session_meta_line; use codex_core::terminal::Multiplexer; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -246,7 +248,6 @@ pub async fn run_main( std::process::exit(1); } - let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -336,16 +337,9 @@ pub async fn run_main( .with(otel_tracing_layer) .try_init(); - run_ratatui_app( - cli, - config, - overrides, - cli_kv_overrides, - active_profile, - feedback, - ) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app(cli, config, overrides, cli_kv_overrides, feedback) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -353,7 +347,6 @@ async fn run_ratatui_app( initial_config: Config, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, - active_profile: Option, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -404,15 +397,15 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, ); let login_status = get_login_status(&initial_config); - let should_show_trust_screen = should_show_trust_screen(&initial_config); + let should_show_trust_screen_flag = should_show_trust_screen(&initial_config); let should_show_onboarding = - should_show_onboarding(login_status, &initial_config, should_show_trust_screen); + should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { show_login_screen: should_show_login_screen(login_status, &initial_config), - show_trust_screen: should_show_trust_screen, + show_trust_screen: should_show_trust_screen_flag, login_status, auth_manager: auth_manager.clone(), config: initial_config.clone(), @@ -437,7 +430,7 @@ async fn run_ratatui_app( .map(|d| d == TrustDirectorySelection::Trust) .unwrap_or(false) { - load_config_or_exit(cli_kv_overrides, overrides).await + load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await } else { initial_config } @@ -570,6 +563,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + let config = match &session_selection { + resume_picker::SessionSelection::Resume(path) + | resume_picker::SessionSelection::Fork(path) => { + let history_cwd = match read_session_meta_line(path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + }; + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + history_cwd, + ) + .await + } + _ => config, + }; + let active_profile = config.active_profile.clone(); + let should_show_trust_screen = should_show_trust_screen(&config); + let Cli { prompt, images, @@ -671,9 +691,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, +) -> Config { + load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await +} + +async fn load_config_or_exit_with_fallback_cwd( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, + fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await { + match ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .fallback_cwd(fallback_cwd) + .build() + .await + { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 27d649c4de..bc224610b3 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -18,6 +18,7 @@ use codex_core::RolloutRecorder; use codex_core::ThreadSortKey; use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; @@ -25,6 +26,7 @@ use codex_core::config::resolve_oss_provider; use codex_core::find_thread_path_by_id_str; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; +use codex_core::read_session_meta_line; use codex_core::terminal::Multiplexer; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -262,7 +264,6 @@ pub async fn run_main( std::process::exit(1); } - let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -355,16 +356,9 @@ pub async fn run_main( let terminal_info = codex_core::terminal::terminal_info(); tracing::info!(terminal = ?terminal_info, "Detected terminal info"); - run_ratatui_app( - cli, - config, - overrides, - cli_kv_overrides, - active_profile, - feedback, - ) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app(cli, config, overrides, cli_kv_overrides, feedback) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -372,7 +366,6 @@ async fn run_ratatui_app( initial_config: Config, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, - active_profile: Option, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -424,15 +417,15 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, ); let login_status = get_login_status(&initial_config); - let should_show_trust_screen = should_show_trust_screen(&initial_config); + let should_show_trust_screen_flag = should_show_trust_screen(&initial_config); let should_show_onboarding = - should_show_onboarding(login_status, &initial_config, should_show_trust_screen); + should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { show_login_screen: should_show_login_screen(login_status, &initial_config), - show_trust_screen: should_show_trust_screen, + show_trust_screen: should_show_trust_screen_flag, login_status, auth_manager: auth_manager.clone(), config: initial_config.clone(), @@ -458,7 +451,7 @@ async fn run_ratatui_app( .map(|d| d == TrustDirectorySelection::Trust) .unwrap_or(false) { - load_config_or_exit(cli_kv_overrides, overrides).await + load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await } else { initial_config } @@ -594,6 +587,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + let config = match &session_selection { + resume_picker::SessionSelection::Resume(path) + | resume_picker::SessionSelection::Fork(path) => { + let history_cwd = match read_session_meta_line(path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + }; + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + history_cwd, + ) + .await + } + _ => config, + }; + let active_profile = config.active_profile.clone(); + let should_show_trust_screen = should_show_trust_screen(&config); + let Cli { prompt, images, @@ -699,9 +719,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, +) -> Config { + load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await +} + +async fn load_config_or_exit_with_fallback_cwd( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, + fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await { + match ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .fallback_cwd(fallback_cwd) + .build() + .await + { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); From b236f1c95db1cfcca80998f0b119a2750d4ea4b3 Mon Sep 17 00:00:00 2001 From: Skylar Graika Date: Tue, 20 Jan 2026 13:07:28 -0800 Subject: [PATCH 03/11] fix: prevent repeating interrupted turns (#9043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Record a model-visible `` marker in history when a turn is interrupted, and treat it as a session prefix. ## Why When a turn is interrupted, Codex emits `TurnAborted` but previously did not persist anything model-visible in the conversation history. On the next user turn, the model can’t tell the previous work was aborted and may resume/repeat earlier actions (including duplicated side effects like re-opening PRs). Fixes: https://github.com/openai/codex/issues/9042 ## How On `TurnAbortReason::Interrupted`, append a hidden user message containing a `…` marker and flush. Treat `` like `` for session-prefix filtering. Add a regression test to ensure follow-up turns don’t repeat side effects from an aborted turn. ## Testing `just fmt` `just fix -p codex-core` `cargo test -p codex-core -- --test-threads=1` `cargo test --all-features -- --test-threads=1` --------- Co-authored-by: Skylar Graika Co-authored-by: jif-oai Co-authored-by: Eric Traut --- codex-rs/core/src/codex.rs | 88 ++++++++++++++------ codex-rs/core/src/compact.rs | 61 +++++++++++++- codex-rs/core/src/context_manager/history.rs | 7 +- codex-rs/core/src/event_mapping.rs | 7 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session_prefix.rs | 15 ++++ codex-rs/core/src/tasks/mod.rs | 24 ++++++ codex-rs/core/tests/suite/abort_tasks.rs | 76 +++++++++++++++++ 8 files changed, 239 insertions(+), 40 deletions(-) create mode 100644 codex-rs/core/src/session_prefix.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6344c90e36..b7dbd07969 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4340,6 +4340,8 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + // Interrupts persist a model-visible `` marker into history, but there is no + // separate client-visible event for that marker (only `EventMsg::TurnAborted`). let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("timeout waiting for event") @@ -4348,6 +4350,7 @@ mod tests { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected event: {other:?}"), } + // No extra events should be emitted after an abort. assert!(rx.try_recv().is_err()); } @@ -4370,11 +4373,17 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - let evt = rx.recv().await.expect("event"); + // Even if tasks handle cancellation gracefully, interrupts still result in `TurnAborted` + // being the only client-visible signal. + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); match evt.msg { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected event: {other:?}"), } + // No extra events should be emitted after an abort. assert!(rx.try_recv().is_err()); } @@ -4390,42 +4399,67 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - // Drain events until we observe ExitedReviewMode; earlier - // RawResponseItem entries (e.g., environment context) may arrive first. - loop { - let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + // Aborting a review task should exit review mode before surfacing the abort to the client. + // We scan for these events (rather than relying on fixed ordering) since unrelated events + // may interleave. + let mut exited_review_mode_idx = None; + let mut turn_aborted_idx = None; + let mut idx = 0usize; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let evt = tokio::time::timeout(remaining, rx.recv()) .await - .expect("timeout waiting for first event") - .expect("first event"); + .expect("timeout waiting for event") + .expect("event"); + let event_idx = idx; + idx = idx.saturating_add(1); match evt.msg { EventMsg::ExitedReviewMode(ev) => { assert!(ev.review_output.is_none()); + exited_review_mode_idx = Some(event_idx); + } + EventMsg::TurnAborted(ev) => { + assert_eq!(TurnAbortReason::Interrupted, ev.reason); + turn_aborted_idx = Some(event_idx); break; } - // Ignore any non-critical events before exit. - _ => continue, - } - } - loop { - let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout waiting for next event") - .expect("event"); - match evt.msg { - EventMsg::RawResponseItem(_) => continue, - EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => continue, - EventMsg::AgentMessage(_) => continue, - EventMsg::TurnAborted(e) => { - assert_eq!(TurnAbortReason::Interrupted, e.reason); - break; - } - other => panic!("unexpected second event: {other:?}"), + _ => {} } } + assert!( + exited_review_mode_idx.is_some(), + "expected ExitedReviewMode after abort" + ); + assert!( + turn_aborted_idx.is_some(), + "expected TurnAborted after abort" + ); + assert!( + exited_review_mode_idx.unwrap() < turn_aborted_idx.unwrap(), + "expected ExitedReviewMode before TurnAborted" + ); - // TODO(jif) investigate what is this? let history = sess.clone_history().await; - let _ = history.raw_items(); + // The `` marker is silent in the event stream, so verify it is still + // recorded in history for the model. + assert!( + history.raw_items().iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "user" { + return false; + } + content.iter().any(|content_item| { + let ContentItem::InputText { text } = content_item else { + return false; + }; + text.contains(crate::session_prefix::TURN_ABORTED_OPEN_TAG) + }) + }), + "expected a model-visible turn aborted marker in history after interrupt" + ); } #[tokio::test] diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index bde807154d..970eb0054d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -15,6 +15,7 @@ use crate::protocol::EventMsg; use crate::protocol::TurnContextItem; use crate::protocol::TurnStartedEvent; use crate::protocol::WarningEvent; +use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; @@ -223,11 +224,31 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { Some(user.message()) } } - _ => None, + _ => collect_turn_aborted_marker(item), }) .collect() } +fn collect_turn_aborted_marker(item: &ResponseItem) -> Option { + let ResponseItem::Message { role, content, .. } = item else { + return None; + }; + if role != "user" { + return None; + } + + let text = content_items_to_text(content)?; + if text + .trim_start() + .to_ascii_lowercase() + .starts_with(TURN_ABORTED_OPEN_TAG) + { + Some(text) + } else { + None + } +} + pub(crate) fn is_summary_message(message: &str) -> bool { message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str()) } @@ -337,6 +358,7 @@ async fn drain_to_completed( mod tests { use super::*; + use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use pretty_assertions::assert_eq; #[test] @@ -489,4 +511,41 @@ mod tests { }; assert_eq!(summary, summary_text); } + + #[test] + fn build_compacted_history_preserves_turn_aborted_markers() { + let marker = format!( + "{TURN_ABORTED_OPEN_TAG}\n turn-1\n interrupted\n" + ); + let items = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: marker.clone(), + }], + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "real user message".to_string(), + }], + }, + ]; + + let user_messages = collect_user_messages(&items); + let history = build_compacted_history(Vec::new(), &user_messages, "SUMMARY"); + + let found_marker = history.iter().any(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).is_some_and(|text| text == marker) + } + _ => false, + }); + assert!( + found_marker, + "expected compacted history to retain marker" + ); + } } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 4feeddc29f..354b3c2d14 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -2,6 +2,7 @@ use crate::codex::TurnContext; use crate::context_manager::normalize; use crate::instructions::SkillInstructions; use crate::instructions::UserInstructions; +use crate::session_prefix::is_session_prefix; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::approx_tokens_from_byte_count; @@ -328,12 +329,6 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize { .saturating_sub(650) } -fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with("") -} - pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { let ResponseItem::Message { role, content, .. } = item else { return false; diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 493a159593..deee79ae70 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -19,14 +19,9 @@ use uuid::Uuid; use crate::instructions::SkillInstructions; use crate::instructions::UserInstructions; +use crate::session_prefix::is_session_prefix; use crate::user_shell_command::is_user_shell_command_text; -fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with("") -} - fn parse_user_message(message: &[ContentItem]) -> Option { if UserInstructions::is_user_instructions(message) || SkillInstructions::is_skill_instructions(message) diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e481f642a3..1fc61a2926 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -46,6 +46,7 @@ pub mod parse_command; pub mod path_utils; pub mod powershell; pub mod sandboxing; +mod session_prefix; mod stream_events_utils; mod text_encoding; pub mod token_data; diff --git a/codex-rs/core/src/session_prefix.rs b/codex-rs/core/src/session_prefix.rs new file mode 100644 index 0000000000..99283082b6 --- /dev/null +++ b/codex-rs/core/src/session_prefix.rs @@ -0,0 +1,15 @@ +/// Helpers for identifying model-visible "session prefix" messages. +/// +/// A session prefix is a user-role message that carries configuration or state needed by +/// follow-up turns (e.g. ``, ``). These items are persisted in +/// history so the model can see them, but they are not user intent and must not create user-turn +/// boundaries. +pub(crate) const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; +pub(crate) const TURN_ABORTED_OPEN_TAG: &str = ""; + +/// Returns true if `text` starts with a session prefix marker (case-insensitive). +pub(crate) fn is_session_prefix(text: &str) -> bool { + let trimmed = text.trim_start(); + let lowered = trimmed.to_ascii_lowercase(); + lowered.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) || lowered.starts_with(TURN_ABORTED_OPEN_TAG) +} diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 83326b1f05..91303a3bdc 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -24,9 +24,13 @@ use crate::protocol::EventMsg; use crate::protocol::TurnAbortReason; use crate::protocol::TurnAbortedEvent; use crate::protocol::TurnCompleteEvent; +use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::RolloutItem; use codex_protocol::user_input::UserInput; pub(crate) use compact::CompactTask; @@ -37,6 +41,7 @@ pub(crate) use undo::UndoTask; pub(crate) use user_shell::UserShellCommandTask; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; +const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn. Do not continue or repeat work from that turn unless the user explicitly asks. If any tools/commands were aborted, they may have partially executed; verify current state before retrying."; /// Thin wrapper that exposes the parts of [`Session`] task runners need. #[derive(Clone)] @@ -242,6 +247,25 @@ impl Session { .abort(session_ctx, Arc::clone(&task.turn_context)) .await; + if reason == TurnAbortReason::Interrupted { + let marker = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!( + "{TURN_ABORTED_OPEN_TAG}\n {sub_id}\n interrupted\n {TURN_ABORTED_INTERRUPTED_GUIDANCE}\n" + ), + }], + }; + self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) + .await; + self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) + .await; + // Ensure the marker is durably visible before emitting TurnAborted: some clients + // synchronously re-read the rollout on receipt of the abort event. + self.flush_rollout().await; + } + let event = EventMsg::TurnAborted(TurnAbortedEvent { reason }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index c020fa00cb..53b5a26b02 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -163,3 +163,79 @@ async fn interrupt_tool_records_history_entries() { "expected at least one tenth of a second of elapsed time, got {secs}" ); } + +/// After an interrupt we persist a model-visible `` marker in the conversation +/// history. This test asserts that the marker is included in the next `/responses` request. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interrupt_persists_turn_aborted_marker_in_next_request() { + let command = "sleep 60"; + let call_id = "call-turn-aborted-marker"; + + let args = json!({ + "command": command, + "timeout_ms": 60_000 + }) + .to_string(); + let first_body = sse(vec![ + ev_response_created("resp-marker"), + ev_function_call(call_id, "shell_command", &args), + ev_completed("resp-marker"), + ]); + let follow_up_body = sse(vec![ + ev_response_created("resp-followup"), + ev_completed("resp-followup"), + ]); + + let server = start_mock_server().await; + let response_mock = mount_sse_sequence(&server, vec![first_body, follow_up_body]).await; + + let fixture = test_codex() + .with_model("gpt-5.1") + .build(&server) + .await + .unwrap(); + let codex = Arc::clone(&fixture.codex); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "start interrupt marker".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await; + + tokio::time::sleep(Duration::from_secs_f32(0.1)).await; + codex.submit(Op::Interrupt).await.unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "follow up".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2, "expected two calls to the responses API"); + + let follow_up_request = &requests[1]; + let user_texts = follow_up_request.message_input_texts("user"); + assert!( + user_texts + .iter() + .any(|text| text.contains("")), + "expected marker in follow-up request" + ); +} From b0049ab644f99eb94529a991e99d8946a70ff01e Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 20 Jan 2026 13:39:31 -0800 Subject: [PATCH 04/11] fix(core): don't update the file's mtime on resume (#9553) Remove `FileTimes::new().set_modified(SystemTime::now())` when resuming a thread. Context: It's awkward in UI built on top of app-server that resuming a thread bumps the `updated_at` timestamp, even if no message is sent. So if you open a thread (perhaps to just view its contents), it automatically reorders it to the top which is almost certainly not what you want. --- codex-rs/core/src/rollout/recorder.rs | 28 ++++++++------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 8c2efcf4e1..c5bd14b3c1 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -1,12 +1,10 @@ //! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later. use std::fs::File; -use std::fs::FileTimes; use std::fs::{self}; use std::io::Error as IoError; use std::path::Path; use std::path::PathBuf; -use std::time::SystemTime; use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; @@ -197,17 +195,14 @@ impl RolloutRecorder { }), ) } - RolloutRecorderParams::Resume { path } => { - touch_rollout_file(&path)?; - ( - tokio::fs::OpenOptions::new() - .append(true) - .open(&path) - .await?, - path, - None, - ) - } + RolloutRecorderParams::Resume { path } => ( + tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await?, + path, + None, + ), }; // Clone the cwd for the spawned task to collect git info asynchronously @@ -392,13 +387,6 @@ fn create_log_file(config: &Config, conversation_id: ThreadId) -> std::io::Resul }) } -fn touch_rollout_file(path: &Path) -> std::io::Result<()> { - let file = fs::OpenOptions::new().append(true).open(path)?; - let times = FileTimes::new().set_modified(SystemTime::now()); - file.set_times(times)?; - Ok(()) -} - async fn rollout_writer( file: tokio::fs::File, mut rx: mpsc::Receiver, From 9ea8e3115e3ace2662ad6369665a96db3a83fded Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 20 Jan 2026 13:55:37 -0800 Subject: [PATCH 05/11] lookup system SIDs instead of hardcoding English strings. (#9552) The elevated setup does not work on non-English windows installs where Users/Administrators/etc are in different languages. This PR uses the well-known SIDs instead, which do not vary based on locale --- .../windows-sandbox-rs/src/sandbox_users.rs | 139 ++++++++++++++++-- .../src/setup_orchestrator.rs | 2 +- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs index 41298b8fba..8aa67a41a5 100644 --- a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs +++ b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs @@ -13,6 +13,7 @@ use std::fs::File; use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; use windows_sys::Win32::NetworkManagement::NetManagement::NERR_Success; use windows_sys::Win32::NetworkManagement::NetManagement::NetLocalGroupAdd; @@ -27,7 +28,10 @@ use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1; use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1003; use windows_sys::Win32::NetworkManagement::NetManagement::USER_PRIV_USER; use windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW; +use windows_sys::Win32::Security::CopySid; +use windows_sys::Win32::Security::GetLengthSid; use windows_sys::Win32::Security::LookupAccountNameW; +use windows_sys::Win32::Security::LookupAccountSidW; use windows_sys::Win32::Security::SID_NAME_USE; use codex_windows_sandbox::dpapi_protect; @@ -38,6 +42,11 @@ use codex_windows_sandbox::SETUP_VERSION; pub const SANDBOX_USERS_GROUP: &str = "CodexSandboxUsers"; const SANDBOX_USERS_GROUP_COMMENT: &str = "Codex sandbox internal group (managed)"; +const SID_ADMINISTRATORS: &str = "S-1-5-32-544"; +const SID_USERS: &str = "S-1-5-32-545"; +const SID_AUTHENTICATED_USERS: &str = "S-1-5-11"; +const SID_EVERYONE: &str = "S-1-1-0"; +const SID_SYSTEM: &str = "S-1-5-18"; pub fn ensure_sandbox_users_group(log: &mut File) -> Result<()> { ensure_local_group(SANDBOX_USERS_GROUP, SANDBOX_USERS_GROUP_COMMENT, log) @@ -119,17 +128,24 @@ pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<( } // Ensure the principal is a regular local user account. - let group = to_wide(OsStr::new("Users")); - let member = LOCALGROUP_MEMBERS_INFO_3 { - lgrmi3_domainandname: name_w.as_ptr() as *mut u16, - }; - let _ = NetLocalGroupAddMembers( - std::ptr::null(), - group.as_ptr(), - 3, - &member as *const _ as *mut u8, - 1, - ); + if let Ok(group_name) = lookup_account_name_for_sid(SID_USERS) { + let group = to_wide(OsStr::new(&group_name)); + let member = LOCALGROUP_MEMBERS_INFO_3 { + lgrmi3_domainandname: name_w.as_ptr() as *mut u16, + }; + let _ = NetLocalGroupAddMembers( + std::ptr::null(), + group.as_ptr(), + 3, + &member as *const _ as *mut u8, + 1, + ); + } else { + super::log_line( + log, + "LookupAccountSidW failed for Users SID; skipping Users group membership", + )?; + } } Ok(()) } @@ -184,6 +200,9 @@ pub fn ensure_local_group_member(group_name: &str, member_name: &str) -> Result< } pub fn resolve_sid(name: &str) -> Result> { + if let Some(sid_str) = well_known_sid_str(name) { + return sid_bytes_from_string(sid_str); + } let name_w = to_wide(OsStr::new(name)); let mut sid_buffer = vec![0u8; 68]; let mut sid_len: u32 = sid_buffer.len() as u32; @@ -218,6 +237,104 @@ pub fn resolve_sid(name: &str) -> Result> { } } +fn well_known_sid_str(name: &str) -> Option<&'static str> { + match name { + "Administrators" => Some(SID_ADMINISTRATORS), + "Users" => Some(SID_USERS), + "Authenticated Users" => Some(SID_AUTHENTICATED_USERS), + "Everyone" => Some(SID_EVERYONE), + "SYSTEM" => Some(SID_SYSTEM), + _ => None, + } +} + +fn sid_bytes_from_string(sid_str: &str) -> Result> { + let sid_w = to_wide(OsStr::new(sid_str)); + let mut psid: *mut c_void = std::ptr::null_mut(); + if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 { + return Err(anyhow::anyhow!( + "ConvertStringSidToSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let sid_len = unsafe { GetLengthSid(psid) }; + if sid_len == 0 { + unsafe { + LocalFree(psid as _); + } + return Err(anyhow::anyhow!("GetLengthSid failed for {sid_str}")); + } + let mut out = vec![0u8; sid_len as usize]; + let ok = unsafe { CopySid(sid_len, out.as_mut_ptr() as *mut c_void, psid) }; + unsafe { + LocalFree(psid as _); + } + if ok == 0 { + return Err(anyhow::anyhow!("CopySid failed for {sid_str}")); + } + Ok(out) +} + +fn lookup_account_name_for_sid(sid_str: &str) -> Result { + let sid_w = to_wide(OsStr::new(sid_str)); + let mut psid: *mut c_void = std::ptr::null_mut(); + if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 { + return Err(anyhow::anyhow!( + "ConvertStringSidToSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let mut name_len: u32 = 0; + let mut domain_len: u32 = 0; + let mut use_type: SID_NAME_USE = 0; + let ok = unsafe { + LookupAccountSidW( + std::ptr::null(), + psid, + std::ptr::null_mut(), + &mut name_len, + std::ptr::null_mut(), + &mut domain_len, + &mut use_type, + ) + }; + if ok == 0 { + let err = unsafe { GetLastError() }; + if err != ERROR_INSUFFICIENT_BUFFER { + unsafe { + LocalFree(psid as _); + } + return Err(anyhow::anyhow!( + "LookupAccountSidW preflight failed for {sid_str}: {err}" + )); + } + } + let mut name_buf: Vec = vec![0u16; name_len as usize]; + let mut domain_buf: Vec = vec![0u16; domain_len as usize]; + let ok = unsafe { + LookupAccountSidW( + std::ptr::null(), + psid, + name_buf.as_mut_ptr(), + &mut name_len, + domain_buf.as_mut_ptr(), + &mut domain_len, + &mut use_type, + ) + }; + unsafe { + LocalFree(psid as _); + } + if ok == 0 { + return Err(anyhow::anyhow!( + "LookupAccountSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let name = String::from_utf16_lossy(&name_buf); + Ok(name.trim_end_matches('\0').to_string()) +} + pub fn sid_bytes_to_psid(sid: &[u8]) -> Result<*mut c_void> { let sid_str = string_from_sid_bytes(sid).map_err(anyhow::Error::msg)?; let sid_w = to_wide(OsStr::new(&sid_str)); diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 30907de7c8..7578e494cd 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -26,7 +26,7 @@ use windows_sys::Win32::Security::CheckTokenMembership; use windows_sys::Win32::Security::FreeSid; use windows_sys::Win32::Security::SECURITY_NT_AUTHORITY; -pub const SETUP_VERSION: u32 = 3; +pub const SETUP_VERSION: u32 = 4; pub const OFFLINE_USERNAME: &str = "CodexSandboxOffline"; pub const ONLINE_USERNAME: &str = "CodexSandboxOnline"; const SECURITY_BUILTIN_DOMAIN_RID: u32 = 0x0000_0020; From f2de920185d074e4b4a4dadfdeab5e95b46f437b Mon Sep 17 00:00:00 2001 From: Max Kong Date: Tue, 20 Jan 2026 16:59:59 -0500 Subject: [PATCH 06/11] fix(windows-sandbox): deny .git file entries under writable roots (#9314) ## Summary - Deny `.git` entries under writable roots even when `.git` is a file (worktrees/submodules) - Add a unit test for `.git` file handling ## Testing - `cargo test -p codex-windows-sandbox` (Windows) Fixes #9313 --- codex-rs/windows-sandbox-rs/src/allow.rs | 33 +++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index 015555dab0..83d72f7e55 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -52,9 +52,9 @@ pub fn compute_allow_paths( let canonical = canonicalize(&candidate).unwrap_or(candidate); add_allow(canonical.clone()); - let git_dir = canonical.join(".git"); - if git_dir.is_dir() { - add_deny(git_dir); + let git_entry = canonical.join(".git"); + if git_entry.exists() { + add_deny(git_entry); } }; @@ -178,6 +178,33 @@ mod tests { assert_eq!(expected_deny, paths.deny); } + #[test] + fn denies_git_file_inside_writable_root() { + let tmp = TempDir::new().expect("tempdir"); + let command_cwd = tmp.path().join("workspace"); + let git_file = command_cwd.join(".git"); + let _ = fs::create_dir_all(&command_cwd); + let _ = fs::write(&git_file, "gitdir: .git/worktrees/example"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: false, + }; + + let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let expected_allow: HashSet = [dunce::canonicalize(&command_cwd).unwrap()] + .into_iter() + .collect(); + let expected_deny: HashSet = [dunce::canonicalize(&git_file).unwrap()] + .into_iter() + .collect(); + + assert_eq!(expected_allow, paths.allow); + assert_eq!(expected_deny, paths.deny); + } + #[test] fn skips_git_dir_when_missing() { let tmp = TempDir::new().expect("tempdir"); From c73a11d55ee7f684762404a8403629eeec4e6267 Mon Sep 17 00:00:00 2001 From: Max Kong Date: Tue, 20 Jan 2026 17:00:27 -0500 Subject: [PATCH 07/11] fix(windows-sandbox): parse PATH list entries for audit roots (#9319) ## Summary - Use `std::env::split_paths` to parse PATH entries in audit candidate collection - Add a unit test covering multiple PATH entries (including spaces) ## Testing - `cargo test -p codex-windows-sandbox` (Windows) Fixes #9317 --- codex-rs/windows-sandbox-rs/src/audit.rs | 45 ++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 659ea57b6f..2e6059e819 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -10,6 +10,7 @@ use anyhow::anyhow; use anyhow::Result; use std::collections::HashSet; use std::ffi::c_void; +use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; use std::time::Duration; @@ -67,9 +68,9 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap .cloned() .or_else(|| std::env::var("PATH").ok()) { - for part in path.split(std::path::MAIN_SEPARATOR) { - if !part.is_empty() { - unique_push(&mut set, &mut out, PathBuf::from(part)); + for part in std::env::split_paths(OsStr::new(&path)) { + if !part.as_os_str().is_empty() { + unique_push(&mut set, &mut out, part); } } } @@ -298,3 +299,41 @@ pub fn apply_capability_denies_for_world_writable( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::gather_candidates; + use std::collections::HashMap; + use std::fs; + + #[test] + fn gathers_path_entries_by_list_separator() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir_a = tmp.path().join("Tools"); + let dir_b = tmp.path().join("Bin"); + let dir_space = tmp.path().join("Program Files"); + fs::create_dir_all(&dir_a).expect("dir a"); + fs::create_dir_all(&dir_b).expect("dir b"); + fs::create_dir_all(&dir_space).expect("dir space"); + + let mut env_map = HashMap::new(); + env_map.insert( + "PATH".to_string(), + format!( + "{};{};{}", + dir_a.display(), + dir_b.display(), + dir_space.display() + ), + ); + + let candidates = gather_candidates(tmp.path(), &env_map); + let canon_a = dir_a.canonicalize().expect("canon a"); + let canon_b = dir_b.canonicalize().expect("canon b"); + let canon_space = dir_space.canonicalize().expect("canon space"); + + assert!(candidates.contains(&canon_a)); + assert!(candidates.contains(&canon_b)); + assert!(candidates.contains(&canon_space)); + } +} From 56fe5e7bea6b18c7b3cbc09cfe6f9e9c2905c30b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 20 Jan 2026 14:02:07 -0800 Subject: [PATCH 08/11] merge remote models (#9547) We have `models.json` and `/models` response Behavior: 1. New models from models endpoint gets added 2. Shared models get replaced by remote ones 3. Existing models in `models.json` but not `/models` are kept 4. Mark highest priority as default --- codex-rs/core/BUILD.bazel | 1 + codex-rs/core/src/models_manager/manager.rs | 70 +++++---- codex-rs/core/tests/suite/remote_models.rs | 166 +++++++++++++++++++- 3 files changed, 203 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index 37a3173f9a..f1649b36c7 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -18,6 +18,7 @@ codex_rust_crate( ), integration_compile_data_extra = [ "//codex-rs/apply-patch:apply_patch_tool_instructions.md", + "models.json", "prompt.md", ], test_data_extra = [ diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index b7cc66159a..7210e1c62d 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -239,7 +239,18 @@ impl ModelsManager { /// Replace the cached remote models and rebuild the derived presets list. async fn apply_remote_models(&self, models: Vec) { - *self.remote_models.write().await = models; + let mut existing_models = Self::load_remote_models_from_file().unwrap_or_default(); + for model in models { + if let Some(existing_index) = existing_models + .iter() + .position(|existing| existing.slug == model.slug) + { + existing_models[existing_index] = model; + } else { + existing_models.push(model); + } + } + *self.remote_models.write().await = existing_models; } fn load_remote_models_from_file() -> Result, std::io::Error> { @@ -272,16 +283,16 @@ impl ModelsManager { let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT); merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode); - let has_default = merged_presets.iter().any(|preset| preset.is_default); - if !has_default { - if let Some(default) = merged_presets - .iter_mut() - .find(|preset| preset.show_in_picker) - { - default.is_default = true; - } else if let Some(default) = merged_presets.first_mut() { - default.is_default = true; - } + for preset in &mut merged_presets { + preset.is_default = false; + } + if let Some(default) = merged_presets + .iter_mut() + .find(|preset| preset.show_in_picker) + { + default.is_default = true; + } else if let Some(default) = merged_presets.first_mut() { + default.is_default = true; } merged_presets @@ -396,6 +407,16 @@ mod tests { .expect("valid model") } + fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) { + for model in expected { + assert!( + actual.iter().any(|candidate| candidate.slug == model.slug), + "expected model {} in cached list", + model.slug + ); + } + } + fn provider_for(base_url: String) -> ModelProviderInfo { ModelProviderInfo { name: "mock".into(), @@ -415,7 +436,7 @@ mod tests { } #[tokio::test] - async fn refresh_available_models_sorts_and_marks_default() { + async fn refresh_available_models_sorts_by_priority() { let server = MockServer::start().await; let remote_models = vec![ remote_model("priority-low", "Low", 1), @@ -447,7 +468,7 @@ mod tests { .await .expect("refresh succeeds"); let cached_remote = manager.get_remote_models(&config).await; - assert_eq!(cached_remote, remote_models); + assert_models_contain(&cached_remote, &remote_models); let available = manager .list_models(&config, RefreshStrategy::OnlineIfUncached) @@ -464,11 +485,6 @@ mod tests { high_idx < low_idx, "higher priority should be listed before lower priority" ); - assert!( - available[high_idx].is_default, - "highest priority should be default" - ); - assert!(!available[low_idx].is_default); assert_eq!( models_mock.requests().len(), 1, @@ -508,22 +524,14 @@ mod tests { .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("first refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - remote_models, - "remote cache should store fetched models" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); // Second call should read from cache and avoid the network. manager .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("cached refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - remote_models, - "cache path should not mutate stored models" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); assert_eq!( models_mock.requests().len(), 1, @@ -587,11 +595,7 @@ mod tests { .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("second refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - updated_models, - "stale cache should trigger refetch" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &updated_models); assert_eq!( initial_mock.requests().len(), 1, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 8a6beb6e24..3b45989d8a 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -1,4 +1,5 @@ #![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used)] // unified exec is not supported on Windows OS use std::sync::Arc; @@ -434,8 +435,18 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { .find(|model| model.model == "remote-alpha") .expect("remote model should be listed"); let mut expected_remote: ModelPreset = remote_model.into(); - expected_remote.is_default = true; + expected_remote.is_default = remote.is_default; assert_eq!(*remote, expected_remote); + let default_model = available + .iter() + .find(|model| model.show_in_picker) + .expect("default model should be set"); + assert!(default_model.is_default); + assert_eq!( + available.iter().filter(|model| model.is_default).count(), + 1, + "expected a single default model" + ); assert!( available .iter() @@ -451,6 +462,148 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_adds_new_high_priority_first() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let remote_model = test_remote_model("remote-top", ModelVisibility::List, -10_000); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + assert_eq!( + available.first().map(|model| model.model.as_str()), + Some("remote-top") + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_replaces_overlapping_model() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let slug = bundled_model_slug(); + let mut remote_model = test_remote_model(&slug, ModelVisibility::List, 0); + remote_model.display_name = "Overridden".to_string(); + remote_model.description = Some("Overridden description".to_string()); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model.clone()], + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + let overridden = available + .iter() + .find(|model| model.model == slug) + .expect("overlapping model should be listed"); + assert_eq!(overridden.display_name, remote_model.display_name); + assert_eq!( + overridden.description, + remote_model + .description + .expect("remote model should include description") + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_preserves_bundled_models_on_empty_response() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let models_mock = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + let bundled_slug = bundled_model_slug(); + assert!( + available.iter().any(|model| model.model == bundled_slug), + "bundled models should remain available after empty remote response" + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_models_request_times_out_after_5s() -> Result<()> { skip_if_no_network!(Ok(())); @@ -588,6 +741,17 @@ async fn wait_for_model_available( } } +fn bundled_model_slug() -> String { + let response: ModelsResponse = serde_json::from_str(include_str!("../../models.json")) + .expect("bundled models.json should deserialize"); + response + .models + .first() + .expect("bundled models.json should include at least one model") + .slug + .clone() +} + fn test_remote_model(slug: &str, visibility: ModelVisibility, priority: i32) -> ModelInfo { test_remote_model_with_policy( slug, From be9e55c5fc394de6a53d31b19625655a196da4a8 Mon Sep 17 00:00:00 2001 From: charley-oai Date: Tue, 20 Jan 2026 14:04:11 -0800 Subject: [PATCH 09/11] Add total (non-partial) TextElement placeholder accessors (#9545) ## Summary - Make `TextElement` placeholders private and add a text-backed accessor to avoid assuming `Some`. - Since they are optional in the protocol, we want to make sure any accessors properly handle the None case (getting the placeholder using the byte range in the text) - Preserve placeholders during protocol/app-server conversions using the accessor fallback. - Update TUI composer/remap logic and tests to use the new constructor/accessor. --- .../app-server-protocol/src/protocol/v1.rs | 7 +- .../app-server-protocol/src/protocol/v2.rs | 32 +++-- .../tests/suite/v2/thread_resume.rs | 8 +- .../app-server/tests/suite/v2/turn_start.rs | 8 +- codex-rs/core/tests/suite/items.rs | 8 +- codex-rs/core/tests/suite/resume.rs | 8 +- codex-rs/protocol/src/items.rs | 15 ++- codex-rs/protocol/src/user_input.rs | 30 ++++- codex-rs/tui/src/bottom_pane/chat_composer.rs | 52 +++---- codex-rs/tui/src/bottom_pane/textarea.rs | 6 +- codex-rs/tui/src/chatwidget.rs | 10 +- codex-rs/tui/src/chatwidget/tests.rs | 127 +++++++++--------- .../tui2/src/bottom_pane/chat_composer.rs | 52 +++---- codex-rs/tui2/src/bottom_pane/textarea.rs | 6 +- codex-rs/tui2/src/chatwidget.rs | 10 +- codex-rs/tui2/src/chatwidget/tests.rs | 127 +++++++++--------- 16 files changed, 260 insertions(+), 246 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 39090c022f..488862b842 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -503,17 +503,14 @@ impl From for V1TextElement { fn from(value: CoreTextElement) -> Self { Self { byte_range: value.byte_range.into(), - placeholder: value.placeholder, + placeholder: value._placeholder_for_conversion_only().map(str::to_string), } } } impl From for CoreTextElement { fn from(value: V1TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 563a78cc6e..0635b530e8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1665,24 +1665,38 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } } impl From for TextElement { fn from(value: CoreTextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new( + value.byte_range.into(), + value._placeholder_for_conversion_only().map(str::to_string), + ) } } impl From for CoreTextElement { fn from(value: TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index be2971397d..89b127d399 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -74,10 +74,10 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let preview = "Saved user message"; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".into()), + )]; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), "2025-01-05T12-00-00", diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 29a7d6b936..c465444709 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -142,10 +142,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".to_string()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".to_string()), + )]; let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 20739ab0db..60d0dbc75f 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -40,10 +40,10 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { let first_response = sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]); mount_sse_once(&server, first_response).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; let expected_input = UserInput::Text { text: "please inspect sample.txt".into(), text_elements: text_elements.clone(), diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 47b67cfe38..d0718a334d 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -35,10 +35,10 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { ]); mount_sse_once(&server, initial_sse).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; codex .submit(Op::UserInput { diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index cb04d427b9..9276a759cc 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -97,13 +97,14 @@ impl UserMessageItem { // Text element ranges are relative to each text chunk; offset them so they align // with the concatenated message returned by `message()`. for elem in text_elements { - out.push(TextElement { - byte_range: ByteRange { - start: offset + elem.byte_range.start, - end: offset + elem.byte_range.end, - }, - placeholder: elem.placeholder.clone(), - }); + let byte_range = ByteRange { + start: offset + elem.byte_range.start, + end: offset + elem.byte_range.end, + }; + out.push(TextElement::new( + byte_range, + elem.placeholder(text).map(str::to_string), + )); } offset += text.len(); } diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index b9ee693d85..0f32f5a33d 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -36,7 +36,35 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + /// Returns the stored placeholder without falling back to the text buffer. + /// + /// This is intended only for protocol conversions where the full text is not + /// available; prefer `placeholder(text)` for UI logic. + #[doc(hidden)] + pub fn _placeholder_for_conversion_only(&self) -> Option<&str> { + self.placeholder.as_deref() + } + + pub fn placeholder<'a>(&'a self, text: &'a str) -> Option<&'a str> { + self.placeholder + .as_deref() + .or_else(|| text.get(self.byte_range.start..self.byte_range.end)) + } } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 55b40a9a06..d54e7005eb 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -557,12 +557,7 @@ impl ChatComposer { let image_placeholders: HashSet = text_elements .iter() - .filter_map(|elem| { - elem.placeholder.as_ref().cloned().or_else(|| { - text.get(elem.byte_range.start..elem.byte_range.end) - .map(str::to_string) - }) - }) + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) .collect(); for (idx, path) in local_image_paths.into_iter().enumerate() { let placeholder = local_image_label_text(idx + 1); @@ -1240,13 +1235,13 @@ impl ChatComposer { return None; } let placeholder = trimmed.get(new_start..new_end).map(str::to_string); - Some(TextElement { - byte_range: ByteRange { + Some(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }) + )) }) .collect() } @@ -1287,7 +1282,7 @@ impl ChatComposer { rebuilt.push_str(&text[cursor..start]); } let elem_text = &text[start..end]; - let placeholder = elem.placeholder; + let placeholder = elem.placeholder(text).map(str::to_string); let replacement = placeholder .as_deref() .and_then(|ph| pending_by_placeholder.get_mut(ph)) @@ -1301,13 +1296,13 @@ impl ChatComposer { rebuilt.push_str(elem_text); let new_end = rebuilt.len(); let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); - rebuilt_elements.push(TextElement { - byte_range: ByteRange { + rebuilt_elements.push(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }); + )); } cursor = end; } @@ -4232,7 +4227,7 @@ mod tests { } => { assert_eq!(text, "[Image #1] hi"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4261,10 +4256,7 @@ mod tests { let placeholder = local_image_label_text(1); let text = format!("{placeholder} restored"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: None, - }]; + let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; let path = PathBuf::from("/tmp/image1.png"); composer.set_text_content(text, text_elements, vec![path.clone()]); @@ -4301,7 +4293,7 @@ mod tests { let expected = format!("{large_content} [Image #1]"); assert_eq!(text, expected); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4345,7 +4337,7 @@ mod tests { let trimmed = large_content.trim().to_string(); assert_eq!(text, format!("{trimmed} [Image #1]")); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4389,7 +4381,7 @@ mod tests { assert_eq!(text, "line1\nline2\n [Image #1]"); assert!(!text.contains('\r')); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4474,7 +4466,7 @@ mod tests { } => { assert_eq!(text, "[Image #1]"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4664,20 +4656,20 @@ mod tests { let start2 = text.find(&placeholder2).expect("placeholder2 present"); let start1 = text.find(&placeholder1).expect("placeholder1 present"); let text_elements = vec![ - TextElement { - byte_range: ByteRange { + TextElement::new( + ByteRange { start: start2, end: start2 + placeholder2.len(), }, - placeholder: Some(placeholder2), - }, - TextElement { - byte_range: ByteRange { + Some(placeholder2), + ), + TextElement::new( + ByteRange { start: start1, end: start1 + placeholder1.len(), }, - placeholder: Some(placeholder1.clone()), - }, + Some(placeholder1.clone()), + ), ]; composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 926c53601a..3a849822b1 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -755,13 +755,13 @@ impl TextArea { .iter() .map(|e| { let placeholder = self.text.get(e.range.clone()).map(str::to_string); - UserTextElement { - byte_range: ByteRange { + UserTextElement::new( + ByteRange { start: e.range.start, end: e.range.end, }, placeholder, - } + ) }) .collect() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 48ed806d3a..8fb3dbacbe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -614,9 +614,9 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) } let original = text.get(start..end).unwrap_or(""); - let placeholder_key = elem.placeholder.as_deref().unwrap_or(original); - let replacement = mapping - .get(placeholder_key) + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) .map(String::as_str) .unwrap_or(original); @@ -624,8 +624,8 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) rebuilt.push_str(replacement); let elem_end = rebuilt.len(); - if let Some(remapped) = mapping.get(placeholder_key) { - elem.placeholder = Some(remapped.clone()); + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); } elem.byte_range = (elem_start..elem_end).into(); rebuilt_elements.push(elem); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 46f5c3be9e..0610a640c7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -192,10 +192,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let message = format!("{placeholder} replayed"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; let conversation_id = ThreadId::new(); @@ -274,10 +274,10 @@ async fn submission_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let text = format!("{placeholder} submit"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/submitted.png")]; chat.bottom_pane @@ -330,26 +330,26 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let first_placeholder = "[Image #1]"; let first_text = format!("{first_placeholder} first"); - let first_elements = vec![TextElement { - byte_range: (0..first_placeholder.len()).into(), - placeholder: Some(first_placeholder.to_string()), - }]; + let first_elements = vec![TextElement::new( + (0..first_placeholder.len()).into(), + Some(first_placeholder.to_string()), + )]; let first_images = [PathBuf::from("/tmp/first.png")]; let second_placeholder = "[Image #1]"; let second_text = format!("{second_placeholder} second"); - let second_elements = vec![TextElement { - byte_range: (0..second_placeholder.len()).into(), - placeholder: Some(second_placeholder.to_string()), - }]; + let second_elements = vec![TextElement::new( + (0..second_placeholder.len()).into(), + Some(second_placeholder.to_string()), + )]; let second_images = [PathBuf::from("/tmp/second.png")]; let existing_placeholder = "[Image #1]"; let existing_text = format!("{existing_placeholder} existing"); - let existing_elements = vec![TextElement { - byte_range: (0..existing_placeholder.len()).into(), - placeholder: Some(existing_placeholder.to_string()), - }]; + let existing_elements = vec![TextElement::new( + (0..existing_placeholder.len()).into(), + Some(existing_placeholder.to_string()), + )]; let existing_images = vec![PathBuf::from("/tmp/existing.png")]; chat.queued_user_messages.push_back(UserMessage { @@ -392,18 +392,18 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let second_start = first.len() + 1; let third_start = second_start + second.len() + 1; let expected_elements = vec![ - TextElement { - byte_range: (first_start..first_start + "[Image #1]".len()).into(), - placeholder: Some("[Image #1]".to_string()), - }, - TextElement { - byte_range: (second_start..second_start + "[Image #2]".len()).into(), - placeholder: Some("[Image #2]".to_string()), - }, - TextElement { - byte_range: (third_start..third_start + "[Image #3]".len()).into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (first_start..first_start + "[Image #1]".len()).into(), + Some("[Image #1]".to_string()), + ), + TextElement::new( + (second_start..second_start + "[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + ), + TextElement::new( + (third_start..third_start + "[Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ]; assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); assert_eq!( @@ -422,14 +422,14 @@ async fn remap_placeholders_uses_attachment_labels() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: Some(placeholder_two.to_string()), - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: Some(placeholder_one.to_string()), - }, + TextElement::new( + (0..placeholder_two.len()).into(), + Some(placeholder_two.to_string()), + ), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + Some(placeholder_one.to_string()), + ), ]; let attachments = vec![ @@ -454,15 +454,14 @@ async fn remap_placeholders_uses_attachment_labels() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( @@ -486,14 +485,11 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: None, - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: None, - }, + TextElement::new((0..placeholder_two.len()).into(), None), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + None, + ), ]; let attachments = vec![ @@ -518,15 +514,14 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 3592b25737..34a4795de9 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -489,12 +489,7 @@ impl ChatComposer { let image_placeholders: HashSet = text_elements .iter() - .filter_map(|elem| { - elem.placeholder.as_ref().cloned().or_else(|| { - text.get(elem.byte_range.start..elem.byte_range.end) - .map(str::to_string) - }) - }) + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) .collect(); for (idx, path) in local_image_paths.into_iter().enumerate() { let placeholder = local_image_label_text(idx + 1); @@ -1174,13 +1169,13 @@ impl ChatComposer { return None; } let placeholder = trimmed.get(new_start..new_end).map(str::to_string); - Some(TextElement { - byte_range: ByteRange { + Some(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }) + )) }) .collect() } @@ -1219,7 +1214,7 @@ impl ChatComposer { rebuilt.push_str(&text[cursor..start]); } let elem_text = &text[start..end]; - let placeholder = elem.placeholder; + let placeholder = elem.placeholder(text).map(str::to_string); let replacement = placeholder .as_deref() .and_then(|ph| pending_by_placeholder.get_mut(ph)) @@ -1231,13 +1226,13 @@ impl ChatComposer { rebuilt.push_str(elem_text); let new_end = rebuilt.len(); let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); - rebuilt_elements.push(TextElement { - byte_range: ByteRange { + rebuilt_elements.push(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }); + )); } cursor = end; } @@ -4223,7 +4218,7 @@ mod tests { } => { assert_eq!(text, "[Image #1] hi"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4252,10 +4247,7 @@ mod tests { let placeholder = local_image_label_text(1); let text = format!("{placeholder} restored"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: None, - }]; + let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; let path = PathBuf::from("/tmp/image1.png"); composer.set_text_content(text, text_elements, vec![path.clone()]); @@ -4292,7 +4284,7 @@ mod tests { let expected = format!("{large_content} [Image #1]"); assert_eq!(text, expected); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4336,7 +4328,7 @@ mod tests { let trimmed = large_content.trim().to_string(); assert_eq!(text, format!("{trimmed} [Image #1]")); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4380,7 +4372,7 @@ mod tests { assert_eq!(text, "line1\nline2\n [Image #1]"); assert!(!text.contains('\r')); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4465,7 +4457,7 @@ mod tests { } => { assert_eq!(text, "[Image #1]"); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4627,20 +4619,20 @@ mod tests { let start2 = text.find(&placeholder2).expect("placeholder2 present"); let start1 = text.find(&placeholder1).expect("placeholder1 present"); let text_elements = vec![ - TextElement { - byte_range: ByteRange { + TextElement::new( + ByteRange { start: start2, end: start2 + placeholder2.len(), }, - placeholder: Some(placeholder2), - }, - TextElement { - byte_range: ByteRange { + Some(placeholder2), + ), + TextElement::new( + ByteRange { start: start1, end: start1 + placeholder1.len(), }, - placeholder: Some(placeholder1.clone()), - }, + Some(placeholder1.clone()), + ), ]; composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs index 37f2d54c7b..7c39379c9f 100644 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -752,13 +752,13 @@ impl TextArea { .iter() .map(|e| { let placeholder = self.text.get(e.range.clone()).map(str::to_string); - UserTextElement { - byte_range: ByteRange { + UserTextElement::new( + ByteRange { start: e.range.start, end: e.range.end, }, placeholder, - } + ) }) .collect() } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index c4cdf76695..23c6195aca 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -556,9 +556,9 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) } let original = text.get(start..end).unwrap_or(""); - let placeholder_key = elem.placeholder.as_deref().unwrap_or(original); - let replacement = mapping - .get(placeholder_key) + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) .map(String::as_str) .unwrap_or(original); @@ -566,8 +566,8 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) rebuilt.push_str(replacement); let elem_end = rebuilt.len(); - if let Some(remapped) = mapping.get(placeholder_key) { - elem.placeholder = Some(remapped.clone()); + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); } elem.byte_range = (elem_start..elem_end).into(); rebuilt_elements.push(elem); diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index f1cf4c32e0..005b015a67 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -181,10 +181,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let message = format!("{placeholder} replayed"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; let conversation_id = ThreadId::new(); @@ -263,10 +263,10 @@ async fn submission_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let text = format!("{placeholder} submit"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/submitted.png")]; chat.bottom_pane @@ -319,26 +319,26 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let first_placeholder = "[Image #1]"; let first_text = format!("{first_placeholder} first"); - let first_elements = vec![TextElement { - byte_range: (0..first_placeholder.len()).into(), - placeholder: Some(first_placeholder.to_string()), - }]; + let first_elements = vec![TextElement::new( + (0..first_placeholder.len()).into(), + Some(first_placeholder.to_string()), + )]; let first_images = [PathBuf::from("/tmp/first.png")]; let second_placeholder = "[Image #1]"; let second_text = format!("{second_placeholder} second"); - let second_elements = vec![TextElement { - byte_range: (0..second_placeholder.len()).into(), - placeholder: Some(second_placeholder.to_string()), - }]; + let second_elements = vec![TextElement::new( + (0..second_placeholder.len()).into(), + Some(second_placeholder.to_string()), + )]; let second_images = [PathBuf::from("/tmp/second.png")]; let existing_placeholder = "[Image #1]"; let existing_text = format!("{existing_placeholder} existing"); - let existing_elements = vec![TextElement { - byte_range: (0..existing_placeholder.len()).into(), - placeholder: Some(existing_placeholder.to_string()), - }]; + let existing_elements = vec![TextElement::new( + (0..existing_placeholder.len()).into(), + Some(existing_placeholder.to_string()), + )]; let existing_images = vec![PathBuf::from("/tmp/existing.png")]; chat.queued_user_messages.push_back(UserMessage { @@ -381,18 +381,18 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { let second_start = first.len() + 1; let third_start = second_start + second.len() + 1; let expected_elements = vec![ - TextElement { - byte_range: (first_start..first_start + "[Image #1]".len()).into(), - placeholder: Some("[Image #1]".to_string()), - }, - TextElement { - byte_range: (second_start..second_start + "[Image #2]".len()).into(), - placeholder: Some("[Image #2]".to_string()), - }, - TextElement { - byte_range: (third_start..third_start + "[Image #3]".len()).into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (first_start..first_start + "[Image #1]".len()).into(), + Some("[Image #1]".to_string()), + ), + TextElement::new( + (second_start..second_start + "[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + ), + TextElement::new( + (third_start..third_start + "[Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ]; assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); assert_eq!( @@ -411,14 +411,14 @@ async fn remap_placeholders_uses_attachment_labels() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: Some(placeholder_two.to_string()), - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: Some(placeholder_one.to_string()), - }, + TextElement::new( + (0..placeholder_two.len()).into(), + Some(placeholder_two.to_string()), + ), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + Some(placeholder_one.to_string()), + ), ]; let attachments = vec![ @@ -443,15 +443,14 @@ async fn remap_placeholders_uses_attachment_labels() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( @@ -475,14 +474,11 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ - TextElement { - byte_range: (0..placeholder_two.len()).into(), - placeholder: None, - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: None, - }, + TextElement::new((0..placeholder_two.len()).into(), None), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + None, + ), ]; let attachments = vec![ @@ -507,15 +503,14 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { assert_eq!( remapped.text_elements, vec![ - TextElement { - byte_range: (0.."[Image #4]".len()).into(), - placeholder: Some("[Image #4]".to_string()), - }, - TextElement { - byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()) - .into(), - placeholder: Some("[Image #3]".to_string()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( From ca23b0da5b75e8f5638a95cfc3f3b6a8d12e9de7 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Wed, 21 Jan 2026 06:53:14 +0800 Subject: [PATCH 10/11] fix(cli): add execute permission to bin/codex.js (#9532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #9520 The `bin/codex.js` file was missing execute permissions (`644` instead of `755`), causing the `codex` command to fail after npm global installation. ## Changes - Added execute permission (`+x`) to `codex-cli/bin/codex.js` ## Verification After this fix, npm tarballs will include the correct file permissions: ```bash # Before: -rw-r--r-- (644) # After: -rwxr-xr-x (755) ``` --- 🤖 Generated with Claude Code Co-authored-by: Claude --- codex-cli/bin/codex.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 codex-cli/bin/codex.js diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js old mode 100644 new mode 100755 From 64678f895ac2ca0e625f11259078a16a21091ad7 Mon Sep 17 00:00:00 2001 From: charley-oai Date: Tue, 20 Jan 2026 14:54:49 -0800 Subject: [PATCH 11/11] Improve UI spacing for queued messages (#9162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Despite good spacing between queued messages and assistant message text: Screenshot 2026-01-12 at 4 54 50 PM Codex has confusing spacing between queued messages and shimmering status text (making the queued message seem like a sub-item of the shimmering status text) Screenshot 2026-01-12 at 4 54 18 PM This PR changes the spacing between the queued message(s) and shimmering status text to make it less confusing: Screenshot 2026-01-13 at 11 20 36 AM While working on the status/queued spacing change, we noticed two paste‑burst tests were timing‑sensitive and could fail on slower CI. We added a small test‑only helper to keep the paste‑burst state active and refreshed during these tests. This removes dependence on tight timing and makes the tests deterministic without affecting runtime behavior. --- codex-rs/tui/src/bottom_pane/mod.rs | 65 +++++++++++++++++-- ...s_visible_when_status_hidden_snapshot.snap | 1 - ...__status_and_queued_messages_snapshot.snap | 2 +- ...tom_pane__tests__status_only_snapshot.snap | 10 +++ ..._details_and_queued_messages_snapshot.snap | 14 ++++ ...i__chatwidget__tests__chatwidget_tall.snap | 3 +- ..._review_queues_user_messages_snapshot.snap | 3 +- codex-rs/tui2/src/bottom_pane/mod.rs | 61 ++++++++++++++++- ...s_visible_when_status_hidden_snapshot.snap | 1 - ...__status_and_queued_messages_snapshot.snap | 2 +- ...tom_pane__tests__status_only_snapshot.snap | 10 +++ ..._details_and_queued_messages_snapshot.snap | 14 ++++ ...2__chatwidget__tests__chatwidget_tall.snap | 3 +- 13 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_only_snapshot.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ee23cad334..3057544197 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -701,11 +701,14 @@ impl BottomPane { if !self.unified_exec_footer.is_empty() { flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer)); } + let has_queued_messages = !self.queued_user_messages.messages.is_empty(); + let has_status_or_footer = + self.status.is_some() || !self.unified_exec_footer.is_empty(); + if has_queued_messages && has_status_or_footer { + flex.push(0, RenderableItem::Owned("".into())); + } flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); - if self.status.is_some() - || !self.unified_exec_footer.is_empty() - || !self.queued_user_messages.messages.is_empty() - { + if !has_queued_messages && has_status_or_footer { flex.push(0, RenderableItem::Owned("".into())); } let mut flex2 = FlexRenderable::new(); @@ -951,6 +954,60 @@ mod tests { ); } + #[test] + fn status_only_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!("status_only_snapshot", render_snapshot(&pane, area)); + } + + #[test] + fn status_with_details_and_queued_messages_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.update_status( + "Working".to_string(), + Some("First detail line\nSecond detail line".to_string()), + ); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "status_with_details_and_queued_messages_snapshot", + render_snapshot(&pane, area) + ); + } + #[test] fn queued_messages_visible_when_status_hidden_snapshot() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap index 123a5eb3a3..5aea415190 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -5,7 +5,6 @@ expression: "render_snapshot(&pane, area)" ↳ Queued follow-up question ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap index 27df671e4d..e651ec9274 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -3,10 +3,10 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- • Working (0s • esc to interrupt) + ↳ Queued follow-up question ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap new file mode 100644 index 0000000000..79e1e126eb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap new file mode 100644 index 0000000000..12090d09e9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + └ First detail line + Second detail line + + ↳ Queued follow-up question + ⌥ + ↑ edit + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap index 6d9aa515b1..64361e90f9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -2,7 +2,9 @@ source: tui/src/chatwidget/tests.rs expression: term.backend().vt100().screen().contents() --- + • Working (0s • esc to interrupt) + ↳ Hello, world! 0 ↳ Hello, world! 1 ↳ Hello, world! 2 @@ -21,7 +23,6 @@ expression: term.backend().vt100().screen().contents() ↳ Hello, world! 15 ↳ Hello, world! 16 - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap index 2a7717df7c..1c02350a6d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 3840 expression: term.backend().vt100().screen().contents() --- @@ -14,10 +13,10 @@ expression: term.backend().vt100().screen().contents() • Working (0s • esc to interrupt) + ↳ Queued while /review is running. ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 83764d55b8..4fa0ae5fe9 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -688,8 +688,13 @@ impl BottomPane { if let Some(status) = &self.status { flex.push(0, RenderableItem::Borrowed(status)); } + let has_queued_messages = !self.queued_user_messages.messages.is_empty(); + let has_status = self.status.is_some(); + if has_queued_messages && has_status { + flex.push(0, RenderableItem::Owned("".into())); + } flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); - if self.status.is_some() || !self.queued_user_messages.messages.is_empty() { + if !has_queued_messages && has_status { flex.push(0, RenderableItem::Owned("".into())); } let mut flex2 = FlexRenderable::new(); @@ -931,6 +936,60 @@ mod tests { ); } + #[test] + fn status_only_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!("status_only_snapshot", render_snapshot(&pane, area)); + } + + #[test] + fn status_with_details_and_queued_messages_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.update_status( + "Working".to_string(), + Some("First detail line\nSecond detail line".to_string()), + ); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "status_with_details_and_queued_messages_snapshot", + render_snapshot(&pane, area) + ); + } + #[test] fn queued_messages_visible_when_status_hidden_snapshot() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap index 71504561db..efc74d42ba 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -5,7 +5,6 @@ expression: "render_snapshot(&pane, area)" ↳ Queued follow-up question ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap index 6ac4296833..cdea8a17d8 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -3,10 +3,10 @@ source: tui2/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- • Working (0s • esc to interrupt) + ↳ Queued follow-up question ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_only_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_only_snapshot.snap new file mode 100644 index 0000000000..3b53b4d862 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_only_snapshot.snap @@ -0,0 +1,10 @@ +--- +source: tui2/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap new file mode 100644 index 0000000000..51236bb69b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + └ First detail line + Second detail line + + ↳ Queued follow-up question + ⌥ + ↑ edit + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap index 3cc0b593d4..57a590c46c 100644 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap @@ -2,7 +2,9 @@ source: tui2/src/chatwidget/tests.rs expression: term.backend().vt100().screen().contents() --- + • Working (0s • esc to interrupt) + ↳ Hello, world! 0 ↳ Hello, world! 1 ↳ Hello, world! 2 @@ -21,7 +23,6 @@ expression: term.backend().vt100().screen().contents() ↳ Hello, world! 15 ↳ Hello, world! 16 - › Ask Codex to do anything 100% context left · ? for shortcuts