diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index a66ff3f3a7..2358d5167e 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -84,6 +84,25 @@ impl fmt::Display for SessionPickerViewMode { } } +/// Working directory to use when resuming or forking a session. +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum ResumeCwdMode { + /// Use the directory where Codex was launched. + Current, + /// Use the latest working directory recorded in the selected session. + Session, +} + +impl ResumeCwdMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Current => "current", + Self::Session => "session", + } + } +} + /// Determine where Codex should store CLI auth credentials. #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] @@ -760,6 +779,11 @@ pub struct Tui { #[serde(default)] pub session_picker_view: Option, + /// Working directory to use when resuming or forking a session. + /// When unset, prompt if the current and session directories differ. + #[serde(default)] + pub resume_cwd: Option, + /// Keybinding overrides for the TUI. /// /// This supports rebinding selected actions globally and by context. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 48dac6cbe5..8ef43b6e94 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2754,6 +2754,25 @@ } ] }, + "ResumeCwdMode": { + "description": "Working directory to use when resuming or forking a session.", + "oneOf": [ + { + "description": "Use the directory where Codex was launched.", + "enum": [ + "current" + ], + "type": "string" + }, + { + "description": "Use the latest working directory recorded in the selected session.", + "enum": [ + "session" + ], + "type": "string" + } + ] + }, "RolloutBudgetConfigToml": { "additionalProperties": false, "properties": { @@ -3285,6 +3304,15 @@ "description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.", "type": "boolean" }, + "resume_cwd": { + "allOf": [ + { + "$ref": "#/definitions/ResumeCwdMode" + } + ], + "default": null, + "description": "Working directory to use when resuming or forking a session. When unset, prompt if the current and session directories differ." + }, "session_picker_view": { "allOf": [ { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 23980fa9ce..ac021f2fe6 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -55,6 +55,7 @@ use codex_config::types::NotificationMethod; use codex_config::types::Notifications; use codex_config::types::OtelConfigToml; use codex_config::types::OtelExporterKind; +use codex_config::types::ResumeCwdMode; use codex_config::types::SandboxWorkspaceWrite; use codex_config::types::SessionPickerViewMode; use codex_config::types::SkillsConfig; @@ -946,6 +947,7 @@ fn config_toml_deserializes_model_availability_nux() { pet: None, pet_anchor: TuiPetAnchor::Composer, session_picker_view: None, + resume_cwd: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig { shown_count: HashMap::from([ @@ -3684,6 +3686,19 @@ session_picker_view = "dense" ); } +#[test] +fn tui_resume_cwd_deserializes_from_toml() { + let cfg = r#" +[tui] +resume_cwd = "current" +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.resume_cwd), + Some(ResumeCwdMode::Current), + ); +} + #[test] fn tui_pet_deserializes_from_toml() { let cfg = r#" @@ -3785,6 +3800,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { pet: None, pet_anchor: TuiPetAnchor::Composer, session_picker_view: None, + resume_cwd: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow_max_rows: None, @@ -3967,6 +3983,35 @@ async fn runtime_config_resolves_session_picker_view_default_and_override() { ); } +#[tokio::test] +async fn runtime_config_resolves_resume_cwd_default_and_override() { + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load default config"); + + assert_eq!(cfg.tui_resume_cwd, None); + + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + resume_cwd: Some(ResumeCwdMode::Session), + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load root override config"); + + assert_eq!(cfg.tui_resume_cwd, Some(ResumeCwdMode::Session)); +} + #[tokio::test] async fn test_sandbox_config_parsing() { let sandbox_full_access = r#" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 2d7a41323b..839cb9a359 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -3,6 +3,7 @@ use crate::path_utils::write_atomically; use anyhow::Context; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; +use codex_config::types::ResumeCwdMode; use codex_config::types::SessionPickerViewMode; use codex_config::types::ToolSuggestDisabledTool; use codex_features::FEATURES; @@ -962,6 +963,14 @@ impl ConfigEditsBuilder { self } + pub fn set_resume_cwd(mut self, mode: ResumeCwdMode) -> Self { + self.edits.push(ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "resume_cwd".to_string()], + value: value(mode.as_str()), + }); + self + } + pub fn with_edits(mut self, edits: I) -> Self where I: IntoIterator, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8239c02c6d..e9b1353a6d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -43,6 +43,7 @@ use codex_config::types::MemoriesConfig; use codex_config::types::ModelAvailabilityNuxConfig; use codex_config::types::Notice; use codex_config::types::OAuthCredentialsStoreMode; +use codex_config::types::ResumeCwdMode; use codex_config::types::SessionPickerViewMode; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDisabledTool; @@ -789,6 +790,10 @@ pub struct Config { /// Preferred layout for resume/fork session picker results. pub tui_session_picker_view: SessionPickerViewMode, + /// Working directory to use when resuming or forking a session. + /// When unset, prompt if the current and session directories differ. + pub tui_resume_cwd: Option, + /// Terminal resize-reflow tuning knobs. pub terminal_resize_reflow: TerminalResizeReflowConfig, @@ -4118,6 +4123,7 @@ impl Config { .as_ref() .and_then(|t| t.session_picker_view) .unwrap_or_default(), + tui_resume_cwd: cfg.tui.as_ref().and_then(|t| t.resume_cwd), terminal_resize_reflow, tui_keymap: cfg .tui diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index c42f29172f..63b33e335a 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -227,6 +227,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R terminal_resize_reflow: TerminalResizeReflowConfig::default(), tui_keymap: TuiKeymap::default(), tui_session_picker_view: SessionPickerViewMode::Dense, + tui_resume_cwd: None, tui_vim_mode_default: false, cwd: cwd.clone(), workspace_roots: vec![cwd], diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dc5ed3f9ad..3066688dd5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -512,6 +512,7 @@ pub(crate) struct App { workspace_command_runner: Option, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, + launch_cwd: PathBuf, pub(crate) state_db: Option, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, @@ -766,6 +767,7 @@ impl App { tui: &mut tui::Tui, mut app_server: AppServerSession, mut config: Config, + launch_cwd: PathBuf, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, @@ -1031,6 +1033,7 @@ See the Codex keymap documentation for supported actions and examples." chat_widget, workspace_command_runner: Some(workspace_command_runner), config, + launch_cwd, state_db, cli_kv_overrides, harness_overrides, diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 6f3323f882..01d73dcfe3 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -7,6 +7,7 @@ use super::*; use crate::app_server_session::source_agent_path; use crate::app_server_session::thread_blocks_direct_input; +use codex_config::types::ResumeCwdMode; #[derive(Clone, Copy)] pub(super) enum ThreadAttachPresentation { @@ -814,31 +815,80 @@ impl App { return Ok(AppRunControl::Continue); } - let current_cwd = self.config.cwd.to_path_buf(); + self.refresh_in_memory_config_from_disk_best_effort("resuming a thread") + .await; + let cwd_override = self + .harness_overrides + .cwd + .as_deref() + .or_else(|| app_server.remote_cwd_override()); + let resume_cwd_mode = crate::session_resume::effective_resume_cwd_mode( + self.config.tui_resume_cwd, + cwd_override, + ); + let remembered_current_cwd = cwd_override.unwrap_or(self.launch_cwd.as_path()); + let current_cwd = if matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) { + remembered_current_cwd.to_path_buf() + } else { + self.config.cwd.to_path_buf() + }; + let uses_remote_workspace_or_environment = crate::uses_remote_workspace_or_environment( + &self.app_server_target, + &self.environment_manager, + ); + if uses_remote_workspace_or_environment + && self.harness_overrides.cwd.is_none() + && app_server.remote_cwd_override().is_none() + && matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) + { + self.chat_widget.add_error_message( + "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" + .to_string(), + ); + return Ok(AppRunControl::Continue); + } let resume_cwd = if self.app_server_target.uses_remote_workspace() { current_cwd.clone() } else { - match crate::session_resume::resolve_cwd_for_resume_or_fork( + let outcome = crate::session_resume::resolve_cwd_for_resume_or_fork( tui, + &self.config, self.state_db.as_deref(), - ¤t_cwd, - target_session.thread_id, - target_session.path.as_deref(), + &target_session, CwdPromptAction::Resume, - /*allow_prompt*/ true, + crate::session_resume::ResumeCwdContext { + current_cwd: ¤t_cwd, + remembered_current_cwd, + allow_remember_current: !uses_remote_workspace_or_environment + || cwd_override.is_some(), + mode: resume_cwd_mode, + }, ) - .await? - { - crate::session_resume::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, - crate::session_resume::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), - crate::session_resume::ResolveCwdOutcome::Exit => { + .await; + match outcome { + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to determine working directory for resume: {err}" + )); + return Ok(AppRunControl::Continue); + } + Ok(crate::session_resume::ResolveCwdOutcome::Continue(Some(cwd))) => cwd, + Ok(crate::session_resume::ResolveCwdOutcome::Continue(None)) => current_cwd.clone(), + Ok(crate::session_resume::ResolveCwdOutcome::Exit) => { return Ok(AppRunControl::Exit(ExitReason::UserRequested)); } } }; + let (config_current_cwd, config_resume_cwd) = + if self.app_server_target.uses_remote_workspace() { + let local_config_cwd = self.config.cwd.to_path_buf(); + (local_config_cwd.clone(), local_config_cwd) + } else { + (current_cwd, resume_cwd) + }; let mut resume_config = match self - .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) + .rebuild_config_for_resume_or_fallback(&config_current_cwd, config_resume_cwd) .await { Ok(cfg) => cfg, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 05240df9bd..d4beb21635 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -22,6 +22,7 @@ pub(super) async fn make_test_app() -> App { app_event_tx, chat_widget, workspace_command_runner: None, + launch_cwd: config.cwd.to_path_buf(), config, state_db: None, cli_kv_overrides: Vec::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index ab0359f136..270dd9d791 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4452,6 +4452,7 @@ async fn make_test_app() -> App { app_event_tx, chat_widget, workspace_command_runner: None, + launch_cwd: config.cwd.to_path_buf(), config, state_db: None, cli_kv_overrides: Vec::new(), @@ -4518,6 +4519,7 @@ async fn make_test_app_with_channels() -> ( app_event_tx, chat_widget, workspace_command_runner: None, + launch_cwd: config.cwd.to_path_buf(), config, state_db: None, cli_kv_overrides: Vec::new(), @@ -5684,6 +5686,436 @@ async fn backtrack_branch_failure_restores_selected_prompt_snapshot() { ); } +#[tokio::test] +async fn remote_resume_current_cwd_rejection_snapshot() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + std::fs::write( + app.config.codex_home.join("config.toml"), + "[tui]\nresume_cwd = \"current\"\n", + )?; + app.app_server_target = crate::AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:4500".to_string(), + auth_token: None, + }, + }; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + let control = app + .resume_target_session( + &mut tui, + &mut app_server, + crate::resume_picker::SessionTarget { + path: None, + thread_id: ThreadId::new(), + }, + ) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert_app_snapshot!("remote_resume_current_cwd_rejected", rendered); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn remote_exec_resume_current_cwd_is_rejected() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + std::fs::write( + app.config.codex_home.join("config.toml"), + "[tui]\nresume_cwd = \"current\"\n", + )?; + app.environment_manager = Arc::new( + EnvironmentManager::create_for_tests( + Some("ws://127.0.0.1:8765".to_string()), + Some(codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?), + ) + .await, + ); + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + let control = app + .resume_target_session( + &mut tui, + &mut app_server, + crate::resume_picker::SessionTarget { + path: None, + thread_id: ThreadId::new(), + }, + ) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert_eq!( + rendered, + "■ `tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" + ); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn in_app_resume_session_cwd_without_metadata_is_non_fatal() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + std::fs::write( + app.config.codex_home.join("config.toml"), + "[tui]\nresume_cwd = \"session\"\n", + )?; + app.state_db = None; + let active_thread_id = app.chat_widget.thread_id(); + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + let control = app + .resume_target_session( + &mut tui, + &mut app_server, + crate::resume_picker::SessionTarget { + path: None, + thread_id: ThreadId::new(), + }, + ) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert_eq!(app.chat_widget.thread_id(), active_thread_id); + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 100)); + assert_app_snapshot!("in_app_resume_session_cwd_without_metadata", rendered); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let local_cwd = app.config.cwd.to_path_buf(); + let local_workspace_roots = app + .rebuild_config_for_cwd(local_cwd.clone()) + .await? + .workspace_roots; + let remote_cwd = if cfg!(windows) { + PathBuf::from("/srv/remote/project") + } else { + PathBuf::from(r"C:\remote\project") + }; + let filename_timestamp = "2025-01-05T12-00-00"; + let thread_id = app_test_support::create_fake_rollout( + app.config.codex_home.as_path(), + filename_timestamp, + "2025-01-05T12:00:00Z", + "Saved user message", + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("materialized rollout should be created"); + let rollout_path = app_test_support::rollout_path( + app.config.codex_home.as_path(), + filename_timestamp, + &thread_id, + ); + app.app_server_target = crate::AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:4500".to_string(), + auth_token: None, + }, + }; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)) + .await? + .with_remote_cwd_override(Some(remote_cwd.clone())); + let mut tui = crate::tui::test_support::make_test_tui()?; + + let control = app + .resume_target_session( + &mut tui, + &mut app_server, + crate::resume_picker::SessionTarget { + path: Some(rollout_path), + thread_id: ThreadId::from_string(&thread_id)?, + }, + ) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert_eq!(app_server.remote_cwd_override(), Some(remote_cwd.as_path())); + assert!(!crate::session_resume::cwds_differ( + app.config.cwd.as_path(), + &local_cwd, + )); + assert_eq!(app.config.workspace_roots, local_workspace_roots); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn in_app_resume_uses_configured_or_explicit_cwd() -> Result<()> { + for (configured_mode, has_explicit_cwd, has_remote_exec, expected_directory) in [ + ("current", false, false, "launch"), + ("session", false, false, "session"), + ("session", true, false, "explicit"), + ("session", false, true, "session"), + ("session", true, true, "explicit"), + ] { + let temp_dir = tempdir()?; + let codex_home = temp_dir.path().join("codex-home"); + let launch_cwd = temp_dir.path().join("launch"); + let active_cwd = temp_dir.path().join("active"); + let session_cwd = temp_dir.path().join("session"); + let explicit_cwd = temp_dir.path().join("explicit"); + std::fs::create_dir_all(&codex_home)?; + std::fs::create_dir_all(&launch_cwd)?; + std::fs::create_dir_all(&active_cwd)?; + std::fs::create_dir_all(&session_cwd)?; + std::fs::create_dir_all(&explicit_cwd)?; + std::fs::write( + codex_home.join("config.toml"), + format!("[tui]\nresume_cwd = \"{configured_mode}\"\n"), + )?; + let config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(ConfigOverrides { + cwd: Some(active_cwd.clone()), + ..Default::default() + }) + .build() + .await?; + let filename_timestamp = "2025-01-05T12-00-00"; + let thread_id = app_test_support::create_fake_rollout( + &codex_home, + filename_timestamp, + "2025-01-05T12:00:00Z", + "Saved user message", + Some(&config.model_provider_id), + /*git_info*/ None, + ) + .expect("materialized rollout should be created"); + let rollout_path = + app_test_support::rollout_path(&codex_home, filename_timestamp, &thread_id); + let mut rollout_lines = std::fs::read_to_string(&rollout_path)? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + rollout_lines[0]["payload"]["cwd"] = serde_json::to_value(&session_cwd)?; + std::fs::write( + &rollout_path, + format!( + "{}\n", + rollout_lines + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n") + ), + )?; + let thread_id = ThreadId::from_string(&thread_id)?; + let state_db = + crate::init_state_db_for_app_server_target(&config, &crate::AppServerTarget::Embedded) + .await?; + let environment_manager = if has_remote_exec { + Arc::new( + EnvironmentManager::create_for_tests( + Some("ws://127.0.0.1:8765".to_string()), + Some(codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?), + ) + .await, + ) + } else { + Arc::new(EnvironmentManager::default_for_tests()) + }; + let mut app_server = crate::start_app_server_for_picker( + &config, + &crate::AppServerTarget::Embedded, + state_db.clone(), + Arc::clone(&environment_manager), + ) + .await?; + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.config = config; + app.launch_cwd = launch_cwd; + app.state_db = state_db; + app.environment_manager = environment_manager; + app.harness_overrides.cwd = has_explicit_cwd.then_some(explicit_cwd); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(ThreadId::new(), active_cwd)); + let mut tui = crate::tui::test_support::make_test_tui()?; + + let control = app + .resume_target_session( + &mut tui, + &mut app_server, + crate::resume_picker::SessionTarget { + path: Some(rollout_path), + thread_id, + }, + ) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + let expected_cwd = temp_dir.path().join(expected_directory); + assert!(!crate::session_resume::cwds_differ( + app.config.cwd.as_path(), + &expected_cwd, + )); + assert!(!crate::session_resume::cwds_differ( + app.chat_widget.config_ref().cwd.as_path(), + &expected_cwd, + )); + assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); + + let control = + Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::ForkCurrentSession)) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert!(!crate::session_resume::cwds_differ( + app.chat_widget.config_ref().cwd.as_path(), + &expected_cwd, + )); + assert_ne!(app.chat_widget.thread_id(), Some(thread_id)); + app_server.shutdown().await?; + } + + Ok(()) +} + +#[tokio::test] +async fn remembered_current_cwd_stays_at_launch_across_in_app_resumes() -> Result<()> { + let temp_dir = tempdir()?; + let codex_home = temp_dir.path().join("codex-home"); + let launch_cwd = temp_dir.path().join("launch"); + let active_cwd = temp_dir.path().join("active"); + let first_session_cwd = temp_dir.path().join("first-session"); + let second_session_cwd = temp_dir.path().join("second-session"); + std::fs::create_dir_all(&codex_home)?; + std::fs::create_dir_all(&launch_cwd)?; + std::fs::create_dir_all(&active_cwd)?; + std::fs::create_dir_all(&first_session_cwd)?; + std::fs::create_dir_all(&second_session_cwd)?; + let config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(ConfigOverrides { + cwd: Some(active_cwd.clone()), + ..Default::default() + }) + .build() + .await?; + let immediately_selected_cwd = crate::cwd_prompt::CwdSelection::CurrentAndRemember + .selected_cwd(&active_cwd, &first_session_cwd, &launch_cwd); + assert!(!crate::session_resume::cwds_differ( + immediately_selected_cwd, + &launch_cwd, + )); + crate::legacy_core::config::edit::ConfigEditsBuilder::for_config(&config) + .set_resume_cwd(codex_config::types::ResumeCwdMode::Current) + .apply() + .await + .map_err(std::io::Error::other)?; + + let mut targets = Vec::new(); + for (filename_timestamp, metadata_timestamp, session_cwd) in [ + ( + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + first_session_cwd, + ), + ( + "2025-01-05T12-01-00", + "2025-01-05T12:01:00Z", + second_session_cwd, + ), + ] { + let thread_id = app_test_support::create_fake_rollout( + &codex_home, + filename_timestamp, + metadata_timestamp, + "Saved user message", + Some(&config.model_provider_id), + /*git_info*/ None, + ) + .expect("materialized rollout should be created"); + let rollout_path = + app_test_support::rollout_path(&codex_home, filename_timestamp, &thread_id); + let mut rollout_lines = std::fs::read_to_string(&rollout_path)? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + rollout_lines[0]["payload"]["cwd"] = serde_json::to_value(&session_cwd)?; + std::fs::write( + &rollout_path, + format!( + "{}\n", + rollout_lines + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n") + ), + )?; + targets.push(crate::resume_picker::SessionTarget { + path: Some(rollout_path), + thread_id: ThreadId::from_string(&thread_id)?, + }); + } + let state_db = + crate::init_state_db_for_app_server_target(&config, &crate::AppServerTarget::Embedded) + .await?; + let mut app_server = crate::start_app_server_for_picker( + &config, + &crate::AppServerTarget::Embedded, + state_db.clone(), + Arc::new(EnvironmentManager::default_for_tests()), + ) + .await?; + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.config = config; + app.launch_cwd = launch_cwd.clone(); + app.state_db = state_db; + app.chat_widget + .handle_thread_session_quiet(test_thread_session(ThreadId::new(), active_cwd)); + let mut tui = crate::tui::test_support::make_test_tui()?; + + for target_session in targets { + let control = app + .resume_target_session(&mut tui, &mut app_server, target_session) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert!(!crate::session_resume::cwds_differ( + app.config.cwd.as_path(), + &launch_cwd, + )); + assert!(!crate::session_resume::cwds_differ( + app.chat_widget.config_ref().cwd.as_path(), + &launch_cwd, + )); + } + app_server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 1ce8eff53e..948aa9bd58 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2133,6 +2133,32 @@ mod tests { assert_eq!(fork.thread_source, Some(ThreadSource::User)); } + #[tokio::test] + async fn remote_resume_params_keep_local_roots_with_cross_platform_cwd_override() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let expected_workspace_roots = config.workspace_roots.clone(); + let remote_cwd = if cfg!(windows) { + std::path::PathBuf::from("/srv/remote/project") + } else { + std::path::PathBuf::from(r"C:\remote\project") + }; + + let resume = thread_resume_params_from_config( + config, + ThreadId::new(), + ThreadParamsMode::Remote, + Some(remote_cwd.as_path()), + ResumeModelSettings::RestoreFromThread, + ); + + assert_eq!(resume.cwd, Some(remote_cwd.to_string_lossy().to_string())); + assert_eq!( + resume.runtime_workspace_roots, + Some(expected_workspace_roots) + ); + } + #[test] fn sandbox_mode_does_not_project_non_cwd_write_roots_for_remote_sessions() { let cwd = test_path_buf("/workspace/project").abs(); diff --git a/codex-rs/tui/src/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs index 264fa39c79..1d89c8bfc0 100644 --- a/codex-rs/tui/src/cwd_prompt.rs +++ b/codex-rs/tui/src/cwd_prompt.rs @@ -1,6 +1,8 @@ use std::path::Path; use crate::key_hint; +use crate::legacy_core::config::Config; +use crate::legacy_core::config::edit::ConfigEditsBuilder; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -9,6 +11,7 @@ use crate::selection_list::selection_option_row; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; +use codex_config::types::ResumeCwdMode; use color_eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -49,6 +52,8 @@ impl CwdPromptAction { pub(crate) enum CwdSelection { Current, Session, + CurrentAndRemember, + SessionAndRemember, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -60,30 +65,60 @@ pub(crate) enum CwdPromptOutcome { impl CwdSelection { fn next(self) -> Self { match self { - CwdSelection::Current => CwdSelection::Session, CwdSelection::Session => CwdSelection::Current, + CwdSelection::Current => CwdSelection::SessionAndRemember, + CwdSelection::SessionAndRemember => CwdSelection::CurrentAndRemember, + CwdSelection::CurrentAndRemember => CwdSelection::Session, } } fn prev(self) -> Self { match self { + CwdSelection::Session => CwdSelection::CurrentAndRemember, CwdSelection::Current => CwdSelection::Session, - CwdSelection::Session => CwdSelection::Current, + CwdSelection::SessionAndRemember => CwdSelection::Current, + CwdSelection::CurrentAndRemember => CwdSelection::SessionAndRemember, + } + } + + fn remembered_mode(self) -> Option { + match self { + CwdSelection::Current | CwdSelection::Session => None, + CwdSelection::CurrentAndRemember => Some(ResumeCwdMode::Current), + CwdSelection::SessionAndRemember => Some(ResumeCwdMode::Session), + } + } + + pub(crate) fn selected_cwd<'path>( + self, + current_cwd: &'path Path, + session_cwd: &'path Path, + remembered_current_cwd: &'path Path, + ) -> &'path Path { + match self { + CwdSelection::Current => current_cwd, + CwdSelection::CurrentAndRemember => remembered_current_cwd, + CwdSelection::Session | CwdSelection::SessionAndRemember => session_cwd, } } } pub(crate) async fn run_cwd_selection_prompt( tui: &mut Tui, + config: &Config, action: CwdPromptAction, current_cwd: &Path, session_cwd: &Path, + remembered_current_cwd: &Path, + allow_remember_current: bool, ) -> Result { let mut screen = CwdPromptScreen::new( tui.frame_requester(), action, current_cwd.display().to_string(), session_cwd.display().to_string(), + remembered_current_cwd.display().to_string(), + allow_remember_current, ); tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); @@ -111,9 +146,29 @@ pub(crate) async fn run_cwd_selection_prompt( if screen.should_exit { Ok(CwdPromptOutcome::Exit) } else { - Ok(CwdPromptOutcome::Selection( - screen.selection().unwrap_or(CwdSelection::Session), - )) + let selection = screen.selection().unwrap_or(CwdSelection::Session); + if let Some(error_line) = persist_remembered_cwd_selection(config, selection).await { + tui.insert_history_lines(vec![error_line]); + } + Ok(CwdPromptOutcome::Selection(selection)) + } +} + +async fn persist_remembered_cwd_selection( + config: &Config, + selection: CwdSelection, +) -> Option> { + let mode = selection.remembered_mode()?; + match ConfigEditsBuilder::for_config(config) + .set_resume_cwd(mode) + .apply() + .await + { + Ok(()) => None, + Err(err) => { + tracing::error!(error = %err, "failed to persist working directory preference"); + Some(Line::from("Failed to save working directory preference.").red()) + } } } @@ -122,9 +177,11 @@ struct CwdPromptScreen { action: CwdPromptAction, current_cwd: String, session_cwd: String, + remembered_current_cwd: String, highlighted: CwdSelection, selection: Option, should_exit: bool, + allow_remember_current: bool, } impl CwdPromptScreen { @@ -133,15 +190,19 @@ impl CwdPromptScreen { action: CwdPromptAction, current_cwd: String, session_cwd: String, + remembered_current_cwd: String, + allow_remember_current: bool, ) -> Self { Self { request_frame, action, current_cwd, session_cwd, + remembered_current_cwd, highlighted: CwdSelection::Session, selection: None, should_exit: false, + allow_remember_current, } } @@ -158,10 +219,26 @@ impl CwdPromptScreen { return; } match key_event.code { - KeyCode::Up | KeyCode::Char('k') => self.set_highlight(self.highlighted.prev()), - KeyCode::Down | KeyCode::Char('j') => self.set_highlight(self.highlighted.next()), + KeyCode::Up | KeyCode::Char('k') => { + let mut highlighted = self.highlighted.prev(); + if !self.allow_remember_current && highlighted == CwdSelection::CurrentAndRemember { + highlighted = highlighted.prev(); + } + self.set_highlight(highlighted); + } + KeyCode::Down | KeyCode::Char('j') => { + let mut highlighted = self.highlighted.next(); + if !self.allow_remember_current && highlighted == CwdSelection::CurrentAndRemember { + highlighted = highlighted.next(); + } + self.set_highlight(highlighted); + } KeyCode::Char('1') => self.select(CwdSelection::Session), KeyCode::Char('2') => self.select(CwdSelection::Current), + KeyCode::Char('3') => self.select(CwdSelection::SessionAndRemember), + KeyCode::Char('4') if self.allow_remember_current => { + self.select(CwdSelection::CurrentAndRemember); + } KeyCode::Enter => self.select(self.highlighted), KeyCode::Esc => self.select(CwdSelection::Session), _ => {} @@ -232,6 +309,26 @@ impl WidgetRef for &CwdPromptScreen { format!("Use current directory ({current_cwd})"), self.highlighted == CwdSelection::Current, )); + column.push(selection_option_row( + /*index*/ 2, + "Always use session directory".to_string(), + self.highlighted == CwdSelection::SessionAndRemember, + )); + if self.allow_remember_current { + let label = if self.remembered_current_cwd == self.current_cwd { + "Always use current directory".to_string() + } else { + format!( + "Always use current directory ({})", + self.remembered_current_cwd + ) + }; + column.push(selection_option_row( + /*index*/ 3, + label, + self.highlighted == CwdSelection::CurrentAndRemember, + )); + } column.push(""); column.push( Line::from(vec![ @@ -250,11 +347,13 @@ impl WidgetRef for &CwdPromptScreen { #[cfg(test)] mod tests { use super::*; + use crate::legacy_core::config::ConfigBuilder; use crate::test_backend::VT100Backend; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; use ratatui::Terminal; + use tempfile::TempDir; fn new_prompt() -> CwdPromptScreen { CwdPromptScreen::new( @@ -262,6 +361,8 @@ mod tests { CwdPromptAction::Resume, "/Users/example/current".to_string(), "/Users/example/session".to_string(), + "/Users/example/current".to_string(), + /*allow_remember_current*/ true, ) } @@ -283,6 +384,8 @@ mod tests { CwdPromptAction::Fork, "/Users/example/current".to_string(), "/Users/example/session".to_string(), + "/Users/example/current".to_string(), + /*allow_remember_current*/ true, ); let mut terminal = Terminal::new(VT100Backend::new(/*width*/ 80, /*height*/ 14)).expect("terminal"); @@ -292,6 +395,54 @@ mod tests { insta::assert_snapshot!("cwd_prompt_fork_modal", terminal.backend()); } + #[test] + fn cwd_prompt_remote_exec_snapshot() { + let screen = CwdPromptScreen::new( + FrameRequester::test_dummy(), + CwdPromptAction::Resume, + "/Users/example/current".to_string(), + "/Users/example/session".to_string(), + "/Users/example/current".to_string(), + /*allow_remember_current*/ false, + ); + let mut terminal = + Terminal::new(VT100Backend::new(/*width*/ 80, /*height*/ 13)).expect("terminal"); + terminal + .draw(|frame| frame.render_widget_ref(&screen, frame.area())) + .expect("render remote exec cwd prompt"); + let rendered = terminal.backend().to_string(); + let rendered = rendered + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + insta::assert_snapshot!("cwd_prompt_remote_exec_modal", rendered); + } + + #[test] + fn cwd_prompt_remembered_current_snapshot() { + let screen = CwdPromptScreen::new( + FrameRequester::test_dummy(), + CwdPromptAction::Resume, + "/Users/example/current".to_string(), + "/Users/example/session".to_string(), + "/Users/example/launched".to_string(), + /*allow_remember_current*/ true, + ); + let mut terminal = + Terminal::new(VT100Backend::new(/*width*/ 80, /*height*/ 14)).expect("terminal"); + terminal + .draw(|frame| frame.render_widget_ref(&screen, frame.area())) + .expect("render remembered current cwd prompt"); + let rendered = terminal.backend().to_string(); + let rendered = rendered + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + insta::assert_snapshot!("cwd_prompt_remembered_current_modal", rendered); + } + #[test] fn cwd_prompt_selects_session_by_default() { let mut screen = new_prompt(); @@ -307,6 +458,23 @@ mod tests { assert_eq!(screen.selection(), Some(CwdSelection::Current)); } + #[test] + fn cwd_prompt_omits_unusable_remembered_current_choice() { + let mut screen = CwdPromptScreen::new( + FrameRequester::test_dummy(), + CwdPromptAction::Resume, + "/Users/example/current".to_string(), + "/Users/example/session".to_string(), + "/Users/example/current".to_string(), + /*allow_remember_current*/ false, + ); + + screen.handle_key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE)); + assert_eq!(screen.selection(), None); + screen.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(screen.highlighted, CwdSelection::SessionAndRemember); + } + #[test] fn cwd_prompt_ctrl_c_exits_instead_of_selecting() { let mut screen = new_prompt(); @@ -314,4 +482,76 @@ mod tests { assert_eq!(screen.selection(), None); assert!(screen.is_done()); } + + #[tokio::test] + async fn cwd_prompt_remembered_choices_select_and_persist_matching_directory() -> Result<()> { + let temp_dir = TempDir::new()?; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await?; + let current_cwd = Path::new("/Users/example/current"); + let session_cwd = Path::new("/Users/example/session"); + let remembered_current_cwd = Path::new("/Users/example/launched"); + + for (key, expected_selection, expected_mode, expected_cwd) in [ + ( + '3', + CwdSelection::SessionAndRemember, + "session", + session_cwd, + ), + ( + '4', + CwdSelection::CurrentAndRemember, + "current", + remembered_current_cwd, + ), + ] { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE)); + let selection = screen.selection().expect("remembered choice is selected"); + + assert_eq!(selection, expected_selection); + assert_eq!( + selection.selected_cwd(current_cwd, session_cwd, remembered_current_cwd), + expected_cwd + ); + assert_eq!( + persist_remembered_cwd_selection(&config, selection).await, + None + ); + let persisted: toml::Value = toml::from_str(&std::fs::read_to_string( + temp_dir.path().join("config.toml"), + )?)?; + assert_eq!(persisted["tui"]["resume_cwd"].as_str(), Some(expected_mode)); + } + + Ok(()) + } + + #[tokio::test] + async fn cwd_prompt_persistence_failure_snapshot() -> Result<()> { + let temp_dir = TempDir::new()?; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await?; + let config_path = temp_dir.path().join("config.toml"); + std::fs::create_dir(&config_path)?; + + let error_line = + persist_remembered_cwd_selection(&config, CwdSelection::CurrentAndRemember) + .await + .expect("saving to a directory should fail"); + let mut terminal = + Terminal::new(VT100Backend::new(/*width*/ 100, /*height*/ 1)).expect("terminal"); + terminal + .draw(|frame| frame.render_widget(error_line, frame.area())) + .expect("render persistence error"); + let rendered = terminal.backend().to_string(); + + insta::assert_snapshot!("cwd_prompt_persistence_failure", rendered); + Ok(()) + } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2d0cb83873..b2de1c31da 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -13,6 +13,8 @@ use crate::legacy_core::config::resolve_bootstrap_auth_route_config; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; use crate::session_resume::ResolveCwdOutcome; +use crate::session_resume::ResumeCwdContext; +use crate::session_resume::effective_resume_cwd_mode; use crate::session_resume::resolve_cwd_for_resume_or_fork; pub use crate::startup_error::LocalStateDbStartupError; use additional_dirs::add_dir_warning_message; @@ -41,6 +43,7 @@ use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; +use codex_config::types::ResumeCwdMode; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; use codex_login::AuthConfig; @@ -757,11 +760,7 @@ fn config_cwd_for_app_server_target( app_server_target: &AppServerTarget, environment_manager: &EnvironmentManager, ) -> std::io::Result> { - if app_server_target.uses_remote_workspace() - || environment_manager - .default_environment() - .is_some_and(|environment| environment.is_remote()) - { + if uses_remote_workspace_or_environment(app_server_target, environment_manager) { return Ok(None); } @@ -774,6 +773,65 @@ fn config_cwd_for_app_server_target( Ok(Some(cwd)) } +fn uses_remote_workspace_or_environment( + app_server_target: &AppServerTarget, + environment_manager: &EnvironmentManager, +) -> bool { + app_server_target.uses_remote_workspace() + || environment_manager + .default_environment() + .is_some_and(|environment| environment.is_remote()) +} + +async fn resolve_startup_resume_or_fork_cwd( + tui: &mut Tui, + config: &Config, + state_db: Option<&codex_state::StateRuntime>, + session_selection: &resume_picker::SessionSelection, + cwd_override: Option<&Path>, + uses_remote_workspace: bool, + uses_remote_workspace_or_environment: bool, +) -> color_eyre::Result { + let Some((action, target_session)) = (match session_selection { + resume_picker::SessionSelection::Resume(target_session) => { + Some((CwdPromptAction::Resume, target_session)) + } + resume_picker::SessionSelection::Fork(target_session) => { + Some((CwdPromptAction::Fork, target_session)) + } + _ => None, + }) else { + return Ok(ResolveCwdOutcome::Continue(None)); + }; + let resume_cwd_mode = effective_resume_cwd_mode(config.tui_resume_cwd, cwd_override); + if uses_remote_workspace_or_environment + && cwd_override.is_none() + && matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) + { + color_eyre::eyre::bail!( + "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" + ); + } + if uses_remote_workspace { + return Ok(ResolveCwdOutcome::Continue(Some(config.cwd.to_path_buf()))); + } + + resolve_cwd_for_resume_or_fork( + tui, + config, + state_db, + target_session, + action, + ResumeCwdContext { + current_cwd: config.cwd.as_path(), + remembered_current_cwd: config.cwd.as_path(), + allow_remember_current: !uses_remote_workspace_or_environment || cwd_override.is_some(), + mode: resume_cwd_mode, + }, + ) + .await +} + fn should_load_configured_environments( loader_overrides: &LoaderOverrides, app_server_target: &AppServerTarget, @@ -1565,48 +1623,34 @@ async fn run_ratatui_app( }; let current_cwd = config.cwd.clone(); - let allow_prompt = !uses_remote_workspace && cli.cwd.is_none(); - let action_and_target_session_if_resume_or_fork = match &session_selection { - resume_picker::SessionSelection::Resume(target_session) => { - Some((CwdPromptAction::Resume, target_session)) + let fallback_cwd = match resolve_startup_resume_or_fork_cwd( + &mut tui, + &config, + state_db.as_deref(), + &session_selection, + cli.cwd.as_deref(), + uses_remote_workspace, + uses_remote_workspace_or_environment(&app_server_target, &environment_manager), + ) + .await + { + Ok(ResolveCwdOutcome::Continue(cwd)) => cwd, + Ok(ResolveCwdOutcome::Exit) => { + terminal_restore_guard.restore_silently(); + session_log::log_session_end(); + return Ok(AppExitInfo { + token_usage: crate::token_usage::TokenUsage::default(), + thread_id: None, + resume_hint: None, + update_action: None, + exit_reason: ExitReason::UserRequested, + }); } - resume_picker::SessionSelection::Fork(target_session) => { - Some((CwdPromptAction::Fork, target_session)) + Err(err) => { + terminal_restore_guard.restore_silently(); + session_log::log_session_end(); + return Err(err); } - _ => None, - }; - let fallback_cwd = match action_and_target_session_if_resume_or_fork { - Some((action, target_session)) => { - if uses_remote_workspace { - Some(current_cwd.to_path_buf()) - } else { - match resolve_cwd_for_resume_or_fork( - &mut tui, - state_db.as_deref(), - ¤t_cwd, - target_session.thread_id, - target_session.path.as_deref(), - action, - allow_prompt, - ) - .await? - { - ResolveCwdOutcome::Continue(cwd) => cwd, - ResolveCwdOutcome::Exit => { - terminal_restore_guard.restore_silently(); - session_log::log_session_end(); - return Ok(AppExitInfo { - token_usage: crate::token_usage::TokenUsage::default(), - thread_id: None, - resume_hint: None, - update_action: None, - exit_reason: ExitReason::UserRequested, - }); - } - } - } - } - None => None, }; let picker_cancelled_without_selection = matches!( @@ -1743,6 +1787,7 @@ async fn run_ratatui_app( &mut tui, app_server, config, + current_cwd.to_path_buf(), cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), @@ -2102,6 +2147,194 @@ mod tests { .await } + #[tokio::test] + async fn startup_resume_and_fork_use_configured_or_explicit_cwd() -> color_eyre::Result<()> { + for (action, configured_mode, has_explicit_cwd, expected_directory) in [ + (CwdPromptAction::Resume, "current", false, "launch"), + (CwdPromptAction::Resume, "session", false, "session"), + (CwdPromptAction::Resume, "session", true, "explicit"), + (CwdPromptAction::Fork, "current", false, "launch"), + (CwdPromptAction::Fork, "session", false, "session"), + (CwdPromptAction::Fork, "session", true, "explicit"), + ] { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().join("codex-home"); + let launch_cwd = temp_dir.path().join("launch"); + let session_cwd = temp_dir.path().join("session"); + let explicit_cwd = temp_dir.path().join("explicit"); + std::fs::create_dir_all(&codex_home)?; + std::fs::create_dir_all(&launch_cwd)?; + std::fs::create_dir_all(&session_cwd)?; + std::fs::create_dir_all(&explicit_cwd)?; + std::fs::write( + codex_home.join("config.toml"), + format!("[tui]\nresume_cwd = \"{configured_mode}\"\n"), + )?; + let cwd_override = has_explicit_cwd.then_some(explicit_cwd.as_path()); + let config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd_override.unwrap_or(launch_cwd.as_path()).to_path_buf()), + ..Default::default() + }) + .build() + .await?; + let filename_timestamp = "2025-01-05T12-00-00"; + let thread_id = write_session_rollout( + &codex_home, + filename_timestamp, + "2025-01-05T12:00:00Z", + "Saved user message", + &config.model_provider_id, + &session_cwd, + )?; + let rollout_path = codex_home + .join("sessions/2025/01/05") + .join(format!("rollout-{filename_timestamp}-{thread_id}.jsonl")); + let state_db = + init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?; + let target_session = resume_picker::SessionTarget { + path: Some(rollout_path), + thread_id, + }; + let session_selection = match action { + CwdPromptAction::Resume => resume_picker::SessionSelection::Resume(target_session), + CwdPromptAction::Fork => resume_picker::SessionSelection::Fork(target_session), + }; + let mut tui = tui::test_support::make_test_tui()?; + + let fallback_cwd = match resolve_startup_resume_or_fork_cwd( + &mut tui, + &config, + state_db.as_deref(), + &session_selection, + cwd_override, + /*uses_remote_workspace*/ false, + /*uses_remote_workspace_or_environment*/ false, + ) + .await? + { + ResolveCwdOutcome::Continue(cwd) => cwd, + ResolveCwdOutcome::Exit => panic!("configured cwd should not exit startup"), + }; + let final_config = ConfigBuilder::default() + .codex_home(codex_home) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(ConfigOverrides { + cwd: cwd_override.map(Path::to_path_buf), + ..Default::default() + }) + .fallback_cwd(fallback_cwd) + .build() + .await?; + let expected_cwd = temp_dir.path().join(expected_directory); + assert!(!session_resume::cwds_differ( + final_config.cwd.as_path(), + &expected_cwd, + )); + let mut app_server = start_app_server_for_picker( + &final_config, + &AppServerTarget::Embedded, + state_db, + Arc::new(EnvironmentManager::default_for_tests()), + ) + .await?; + let started = match action { + CwdPromptAction::Resume => { + app_server + .resume_thread( + final_config, + thread_id, + app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await? + } + CwdPromptAction::Fork => app_server.fork_thread(final_config, thread_id).await?, + }; + + assert!(!session_resume::cwds_differ( + started.session.cwd.as_path(), + &expected_cwd, + )); + app_server.shutdown().await?; + } + + Ok(()) + } + + #[tokio::test] + async fn startup_remote_current_cwd_without_override_is_rejected() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + std::fs::write( + temp_dir.path().join("config.toml"), + "[tui]\nresume_cwd = \"current\"\n", + )?; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + let mut tui = tui::test_support::make_test_tui()?; + + let error = resolve_startup_resume_or_fork_cwd( + &mut tui, + &config, + /*state_db*/ None, + &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { + path: None, + thread_id: ThreadId::new(), + }), + /*cwd_override*/ None, + /*uses_remote_workspace*/ false, + /*uses_remote_workspace_or_environment*/ true, + ) + .await + .expect_err("remote current cwd should require an explicit override"); + + assert_eq!( + error.to_string(), + "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" + ); + Ok(()) + } + + #[tokio::test] + async fn startup_session_cwd_without_metadata_is_rejected() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + std::fs::write( + temp_dir.path().join("config.toml"), + "[tui]\nresume_cwd = \"session\"\n", + )?; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + let mut tui = tui::test_support::make_test_tui()?; + + let error = resolve_startup_resume_or_fork_cwd( + &mut tui, + &config, + /*state_db*/ None, + &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { + path: None, + thread_id: ThreadId::new(), + }), + /*cwd_override*/ None, + /*uses_remote_workspace*/ false, + /*uses_remote_workspace_or_environment*/ false, + ) + .await + .expect_err("session cwd should require saved metadata"); + + assert_eq!( + error.to_string(), + "failed to determine the working directory recorded for the selected session" + ); + Ok(()) + } + #[test] fn alternate_screen_auto_uses_alt_screen() { assert!(determine_alt_screen_mode( diff --git a/codex-rs/tui/src/session_resume.rs b/codex-rs/tui/src/session_resume.rs index 56b258704e..2e86baed4b 100644 --- a/codex-rs/tui/src/session_resume.rs +++ b/codex-rs/tui/src/session_resume.rs @@ -11,8 +11,10 @@ use std::path::PathBuf; use crate::cwd_prompt; use crate::cwd_prompt::CwdPromptAction; use crate::cwd_prompt::CwdPromptOutcome; -use crate::cwd_prompt::CwdSelection; +use crate::legacy_core::config::Config; +use crate::resume_picker::SessionTarget; use crate::tui::Tui; +use codex_config::types::ResumeCwdMode; use codex_protocol::ThreadId; use codex_rollout::open_rollout_line_reader; use codex_state::StateRuntime; @@ -46,11 +48,30 @@ struct RawRecord { payload: Option, } +#[derive(Debug, PartialEq, Eq)] pub(crate) enum ResolveCwdOutcome { Continue(Option), Exit, } +pub(crate) struct ResumeCwdContext<'path> { + pub(crate) current_cwd: &'path Path, + pub(crate) remembered_current_cwd: &'path Path, + pub(crate) allow_remember_current: bool, + pub(crate) mode: Option, +} + +pub(crate) fn effective_resume_cwd_mode( + configured_mode: Option, + cwd_override: Option<&Path>, +) -> Option { + if cwd_override.is_some() { + Some(ResumeCwdMode::Current) + } else { + configured_mode + } +} + pub(crate) async fn resolve_session_thread_id( path: &Path, id_str_if_uuid: Option<&str>, @@ -85,26 +106,58 @@ pub(crate) async fn read_session_model( pub(crate) async fn resolve_cwd_for_resume_or_fork( tui: &mut Tui, + config: &Config, state_db_ctx: Option<&StateRuntime>, - current_cwd: &Path, - thread_id: ThreadId, - path: Option<&Path>, + target_session: &SessionTarget, action: CwdPromptAction, - allow_prompt: bool, + cwd_context: ResumeCwdContext<'_>, ) -> color_eyre::Result { - let Some(history_cwd) = read_session_cwd(state_db_ctx, thread_id, path).await else { + if matches!(cwd_context.mode, Some(ResumeCwdMode::Current)) { + return Ok(ResolveCwdOutcome::Continue(Some( + cwd_context.remembered_current_cwd.to_path_buf(), + ))); + } + let Some(history_cwd) = read_session_cwd( + state_db_ctx, + target_session.thread_id, + target_session.path.as_deref(), + ) + .await + else { + if matches!(cwd_context.mode, Some(ResumeCwdMode::Session)) { + color_eyre::eyre::bail!( + "failed to determine the working directory recorded for the selected session" + ); + } return Ok(ResolveCwdOutcome::Continue(None)); }; - if allow_prompt && cwds_differ(current_cwd, &history_cwd) { - let selection_outcome = - cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?; + match cwd_context.mode { + Some(ResumeCwdMode::Session) => { + return Ok(ResolveCwdOutcome::Continue(Some(history_cwd))); + } + Some(ResumeCwdMode::Current) | None => {} + } + if cwds_differ(cwd_context.current_cwd, &history_cwd) { + let selection_outcome = cwd_prompt::run_cwd_selection_prompt( + tui, + config, + action, + cwd_context.current_cwd, + &history_cwd, + cwd_context.remembered_current_cwd, + cwd_context.allow_remember_current, + ) + .await?; return Ok(match selection_outcome { - CwdPromptOutcome::Selection(CwdSelection::Current) => { - ResolveCwdOutcome::Continue(Some(current_cwd.to_path_buf())) - } - CwdPromptOutcome::Selection(CwdSelection::Session) => { - ResolveCwdOutcome::Continue(Some(history_cwd)) - } + CwdPromptOutcome::Selection(selection) => ResolveCwdOutcome::Continue(Some( + selection + .selected_cwd( + cwd_context.current_cwd, + &history_cwd, + cwd_context.remembered_current_cwd, + ) + .to_path_buf(), + )), CwdPromptOutcome::Exit => ResolveCwdOutcome::Exit, }); } @@ -308,4 +361,193 @@ mod tests { assert_eq!(state.cwd, Some(cwd)); Ok(()) } + + #[tokio::test] + async fn rollout_resume_state_preserves_legacy_fork_child_context() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let thread_id = ThreadId::from_string("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + .expect("legacy thread id"); + let parent_thread_id = ThreadId::from_string("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + .expect("legacy parent id"); + let child_cwd = temp_dir.path().join("child"); + let rollout_path = temp_dir.path().join("rollout.jsonl"); + write_rollout_lines( + &rollout_path, + &[ + rollout_line( + "t0", + "session_meta", + serde_json::json!({ + "id": thread_id, + "forked_from_id": parent_thread_id, + "cwd": temp_dir.path().join("initial"), + "originator": "test", + "cli_version": "test", + }), + ), + rollout_line( + "t1", + "event_msg", + serde_json::json!({ "type": "task_started", "turn_id": "legacy-child-turn" }), + ), + rollout_line( + "t2", + "turn_context", + serde_json::json!({ "cwd": child_cwd.clone(), "model": "child-model" }), + ), + ], + )?; + + let state = read_rollout_resume_state(&rollout_path).await?; + + assert_eq!(state.thread_id, Some(thread_id)); + assert_eq!(state.cwd, Some(child_cwd)); + assert_eq!(state.model.as_deref(), Some("child-model")); + Ok(()) + } + + #[tokio::test] + async fn session_cwd_prefers_state_metadata_over_rollout_context() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let thread_id = ThreadId::new(); + let session_cwd = temp_dir.path().join("child"); + let rollout_path = temp_dir.path().join("rollout.jsonl"); + write_rollout_lines( + &rollout_path, + &[ + rollout_line( + "t0", + "session_meta", + serde_json::json!({ + "id": thread_id, + "cwd": session_cwd, + "originator": "test", + "cli_version": "test", + }), + ), + rollout_line( + "t1", + "turn_context", + serde_json::json!({ + "cwd": temp_dir.path().join("copied-parent"), + "model": "parent-model", + }), + ), + ], + )?; + let state_runtime = + StateRuntime::init(temp_dir.path().to_path_buf(), "test-provider".to_string()) + .await + .map_err(std::io::Error::other)?; + let created_at = chrono::DateTime::parse_from_rfc3339("2025-01-05T12:00:00Z") + .expect("timestamp should parse") + .with_timezone(&chrono::Utc); + let mut builder = codex_state::ThreadMetadataBuilder::new( + thread_id, + rollout_path.clone(), + created_at, + serde_json::from_value(serde_json::json!("cli")) + .expect("cli session source should deserialize"), + ); + builder.cwd = session_cwd.clone(); + state_runtime + .upsert_thread(&builder.build("test-provider")) + .await + .map_err(std::io::Error::other)?; + + let cwd = + read_session_cwd(Some(state_runtime.as_ref()), thread_id, Some(&rollout_path)).await; + + assert_eq!(cwd, Some(session_cwd)); + Ok(()) + } + + #[tokio::test] + async fn configured_resume_cwd_skips_prompt() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let thread_id = ThreadId::new(); + let session_cwd = temp_dir.path().join("session"); + let rollout_path = temp_dir.path().join("rollout.jsonl"); + let config = crate::legacy_core::config::ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await?; + let current_cwd = config.cwd.to_path_buf(); + write_rollout_lines( + &rollout_path, + &[rollout_line( + "t0", + "session_meta", + serde_json::json!({ + "id": thread_id, + "cwd": session_cwd.clone(), + "originator": "test", + "cli_version": "test", + }), + )], + )?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + for (cwd_mode, expected_cwd) in [ + (ResumeCwdMode::Current, current_cwd.clone()), + (ResumeCwdMode::Session, session_cwd), + ] { + let outcome = resolve_cwd_for_resume_or_fork( + &mut tui, + &config, + /*state_db_ctx*/ None, + &SessionTarget { + path: Some(rollout_path.clone()), + thread_id, + }, + CwdPromptAction::Fork, + ResumeCwdContext { + current_cwd: ¤t_cwd, + remembered_current_cwd: ¤t_cwd, + allow_remember_current: true, + mode: Some(cwd_mode), + }, + ) + .await?; + + assert_eq!(outcome, ResolveCwdOutcome::Continue(Some(expected_cwd))); + } + Ok(()) + } + + #[tokio::test] + async fn configured_session_cwd_rejects_missing_metadata() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let config = crate::legacy_core::config::ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await?; + let current_cwd = config.cwd.to_path_buf(); + let mut tui = crate::tui::test_support::make_test_tui()?; + + let error = resolve_cwd_for_resume_or_fork( + &mut tui, + &config, + /*state_db_ctx*/ None, + &SessionTarget { + path: None, + thread_id: ThreadId::new(), + }, + CwdPromptAction::Resume, + ResumeCwdContext { + current_cwd: ¤t_cwd, + remembered_current_cwd: ¤t_cwd, + allow_remember_current: true, + mode: Some(ResumeCwdMode::Session), + }, + ) + .await + .expect_err("session mode should reject unavailable metadata"); + + assert_eq!( + error.to_string(), + "failed to determine the working directory recorded for the selected session" + ); + Ok(()) + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__in_app_resume_session_cwd_without_metadata.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__in_app_resume_session_cwd_without_metadata.snap new file mode 100644 index 0000000000..8f37179e50 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__in_app_resume_session_cwd_without_metadata.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/tests.rs +expression: rendered +--- +■ Failed to determine working directory for resume: failed to determine the working directory recorded for the selected session diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__remote_resume_current_cwd_rejected.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__remote_resume_current_cwd_rejected.snap new file mode 100644 index 0000000000..5911b08eb2 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__remote_resume_current_cwd_rejected.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/tests.rs +expression: rendered +--- +■ `tui.resume_cwd = "current"` requires `--cd` when using a remote workspace diff --git a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap index 38b712e872..ba2dd5065f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap @@ -10,5 +10,7 @@ Choose working directory to fork this session › 1. Use session directory (/Users/example/session) 2. Use current directory (/Users/example/current) + 3. Always use session directory + 4. Always use current directory Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap index 4e87cfdfc7..c0a5e7b12d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap @@ -10,5 +10,7 @@ Choose working directory to resume this session › 1. Use session directory (/Users/example/session) 2. Use current directory (/Users/example/current) + 3. Always use session directory + 4. Always use current directory Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_persistence_failure.snap b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_persistence_failure.snap new file mode 100644 index 0000000000..781bee771d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_persistence_failure.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/cwd_prompt.rs +expression: rendered +--- +Failed to save working directory preference. diff --git a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_remembered_current_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_remembered_current_modal.snap new file mode 100644 index 0000000000..e0a3afdeeb --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_remembered_current_modal.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/cwd_prompt.rs +expression: rendered +--- + +Choose working directory to resume this session + + Session = latest cwd recorded in the resumed session + Current = your current working directory + +› 1. Use session directory (/Users/example/session) + 2. Use current directory (/Users/example/current) + 3. Always use session directory + 4. Always use current directory (/Users/example/launched) + + Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_remote_exec_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_remote_exec_modal.snap new file mode 100644 index 0000000000..971b009276 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_remote_exec_modal.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/cwd_prompt.rs +expression: rendered +--- + +Choose working directory to resume this session + + Session = latest cwd recorded in the resumed session + Current = your current working directory + +› 1. Use session directory (/Users/example/session) + 2. Use current directory (/Users/example/current) + 3. Always use session directory + + Press enter to continue