diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e47415d9f0..4273bc0686 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,8 +1,8 @@ use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; use crate::app_event::ExitMode; -use crate::app_event::RealtimeAudioDeviceKind; use crate::app_event::ForkPanePlacement; +use crate::app_event::RealtimeAudioDeviceKind; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; @@ -45,6 +45,7 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::ThreadManager; +use codex_core::codex::SessionSettingsUpdate; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -98,6 +99,8 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; +use std::fs; +use std::io; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -115,6 +118,7 @@ use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::unbounded_channel; use tokio::task::JoinHandle; use toml::Value as TomlValue; +use uuid::Uuid; mod pending_interactive_replay; @@ -1425,8 +1429,8 @@ impl App { } fn fresh_session_config(&self) -> Config { - let mut config = self.config.clone(); - config.service_tier = self.chat_widget.current_service_tier(); + let mut config = self.chat_widget.config_ref().clone(); + config.model = Some(self.chat_widget.current_model().to_string()); config } @@ -1444,16 +1448,62 @@ impl App { let Some(multiplexer) = terminal_info.multiplexer.as_ref() else { return false; }; - let description = - match spawn_fork_in_new_pane(multiplexer, &forked_thread_id, placement).await { - Ok(description) => description, - Err(err) => { - self.chat_widget.add_error_message(format!( - "Forked session created but failed to open a new pane: {err}" - )); - return false; - } - }; + let thread_snapshot = forked_thread.config_snapshot().await; + let current_collaboration_mode = self.chat_widget.current_collaboration_mode(); + let runtime_overrides = SessionSettingsUpdate { + cwd: Some(thread_snapshot.cwd.clone()), + approval_policy: Some(thread_snapshot.approval_policy), + sandbox_policy: Some(thread_snapshot.sandbox_policy.clone()), + windows_sandbox_level: None, + collaboration_mode: Some(CollaborationMode { + mode: self.chat_widget.active_collaboration_mode_kind(), + settings: Settings { + model: thread_snapshot.model.clone(), + reasoning_effort: thread_snapshot.reasoning_effort, + developer_instructions: current_collaboration_mode + .settings + .developer_instructions + .clone(), + }, + }), + reasoning_summary: None, + service_tier: Some(self.chat_widget.current_service_tier()), + final_output_json_schema: None, + personality: thread_snapshot.personality, + app_server_client_name: None, + }; + let runtime_overrides_path = match (|| -> io::Result { + let overrides_dir = self.config.codex_home.join("tmp").join("runtime-overrides"); + fs::create_dir_all(&overrides_dir)?; + let path = overrides_dir.join(format!("{}.json", Uuid::new_v4())); + let contents = serde_json::to_vec(&runtime_overrides).map_err(io::Error::other)?; + fs::write(&path, contents)?; + Ok(path) + })() { + Ok(path) => path, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Forked session created but failed to prepare runtime overrides: {err}" + )); + return false; + } + }; + let description = match spawn_fork_in_new_pane( + multiplexer, + &forked_thread_id, + &runtime_overrides_path, + placement, + ) + .await + { + Ok(description) => description, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Forked session created but failed to open a new pane: {err}" + )); + return false; + } + }; self.suppressed_thread_created.insert(forked_thread_id); if let Err(err) = forked_thread.submit(Op::Shutdown).await { @@ -5036,13 +5086,15 @@ mod tests { } #[tokio::test] - async fn fresh_session_config_uses_current_service_tier() { + async fn fresh_session_config_uses_live_chat_widget_state() { let mut app = make_test_app().await; + app.chat_widget.set_model("gpt-test"); app.chat_widget .set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast)); let config = app.fresh_session_config(); + assert_eq!(config.model.as_deref(), Some("gpt-test")); assert_eq!( config.service_tier, Some(codex_protocol::config_types::ServiceTier::Fast) diff --git a/codex-rs/tui/src/terminal_multiplexer.rs b/codex-rs/tui/src/terminal_multiplexer.rs index 31ccd0bb7d..791e52b35d 100644 --- a/codex-rs/tui/src/terminal_multiplexer.rs +++ b/codex-rs/tui/src/terminal_multiplexer.rs @@ -3,6 +3,8 @@ use codex_core::terminal::Multiplexer; use codex_core::terminal::terminal_info; use codex_protocol::ThreadId; use shlex::try_join; +use std::fs; +use std::io; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -14,14 +16,42 @@ pub(crate) struct MultiplexerSpawnConfig { } fn codex_executable() -> PathBuf { - std::env::current_exe().unwrap_or_else(|_| PathBuf::from("codex")) + std::env::current_exe() + .map(|path| resolve_codex_executable(&path)) + .unwrap_or_else(|_| PathBuf::from("codex")) } -fn resume_command_parts(exe: &Path, thread_id: &ThreadId) -> Vec { +fn resolve_codex_executable(current_exe: &Path) -> PathBuf { + let Some(file_name) = current_exe.file_name().and_then(|name| name.to_str()) else { + return PathBuf::from("codex"); + }; + if !file_name.starts_with("codex-tui") { + return current_exe.to_path_buf(); + } + + let sibling = if let Some((_, extension)) = file_name.rsplit_once('.') { + current_exe.with_file_name(format!("codex.{extension}")) + } else { + current_exe.with_file_name("codex") + }; + if sibling.is_file() { + sibling + } else { + PathBuf::from("codex") + } +} + +fn resume_command_parts( + exe: &Path, + thread_id: &ThreadId, + runtime_overrides_path: &Path, +) -> Vec { vec![ exe.display().to_string(), "resume".to_string(), thread_id.to_string(), + "--runtime-overrides".to_string(), + runtime_overrides_path.display().to_string(), ] } @@ -86,9 +116,10 @@ fn fork_spawn_config( multiplexer: &Multiplexer, exe: &Path, thread_id: &ThreadId, + runtime_overrides_path: &Path, placement: Option, ) -> MultiplexerSpawnConfig { - let resume_command = resume_command_parts(exe, thread_id); + let resume_command = resume_command_parts(exe, thread_id, runtime_overrides_path); match multiplexer { Multiplexer::Zellij {} => MultiplexerSpawnConfig { program: "zellij", @@ -138,21 +169,97 @@ pub(crate) fn validate_fork_placement(placement: Option) -> R pub(crate) async fn spawn_fork_in_new_pane( multiplexer: &Multiplexer, thread_id: &ThreadId, + runtime_overrides_path: &Path, placement: Option, ) -> Result<&'static str, String> { let exe = codex_executable(); - let config = fork_spawn_config(multiplexer, &exe, thread_id, placement); + let config = fork_spawn_config( + multiplexer, + &exe, + thread_id, + runtime_overrides_path, + placement, + ); let MultiplexerSpawnConfig { program, args, description, } = config; - let status = tokio::task::spawn_blocking(move || Command::new(program).args(args).status()) - .await - .map_err(|err| format!("failed to spawn {program} pane: {err}"))?; + let status = match tokio::task::spawn_blocking(move || { + Command::new(program).args(args).status() + }) + .await + { + Ok(status) => status, + Err(err) => { + cleanup_runtime_overrides(runtime_overrides_path); + return Err(format!("failed to spawn {program} pane: {err}")); + } + }; match status { Ok(status) if status.success() => Ok(description), - Ok(status) => Err(format!("{program} exited with status {status}")), - Err(err) => Err(format!("failed to run {program}: {err}")), + Ok(status) => { + cleanup_runtime_overrides(runtime_overrides_path); + Err(format!("{program} exited with status {status}")) + } + Err(err) => { + cleanup_runtime_overrides(runtime_overrides_path); + Err(format!("failed to run {program}: {err}")) + } + } +} + +fn cleanup_runtime_overrides(path: &Path) { + if let Err(err) = fs::remove_file(path) + && err.kind() != io::ErrorKind::NotFound + { + tracing::warn!(path = %path.display(), error = %err, "failed to remove runtime overrides"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn resolve_codex_executable_rewrites_codex_tui_to_sibling_codex() { + let tmp = tempfile::tempdir().expect("tempdir"); + let current_exe = tmp.path().join("codex-tui"); + let sibling_codex = tmp.path().join("codex"); + std::fs::write(&sibling_codex, "").expect("write sibling codex"); + + let resolved = resolve_codex_executable(¤t_exe); + + assert_eq!(resolved, sibling_codex); + } + + #[test] + fn resolve_codex_executable_keeps_non_tui_binary() { + let current_exe = PathBuf::from("/tmp/codex"); + + let resolved = resolve_codex_executable(¤t_exe); + + assert_eq!(resolved, current_exe); + } + + #[test] + fn resume_command_parts_include_runtime_overrides() { + let exe = PathBuf::from("/tmp/codex"); + let thread_id = ThreadId::new(); + let runtime_overrides_path = PathBuf::from("/tmp/runtime-overrides.json"); + + let command = resume_command_parts(&exe, &thread_id, &runtime_overrides_path); + + assert_eq!( + command, + vec![ + "/tmp/codex".to_string(), + "resume".to_string(), + thread_id.to_string(), + "--runtime-overrides".to_string(), + "/tmp/runtime-overrides.json".to_string(), + ] + ); } }