Let users remember the working directory for resumed sessions (#33950)

## What changed

- Add `tui.resume_cwd` with `current` and `session` modes for resume and fork flows.
- Let users persist either mode directly from the working-directory prompt, while preserving one-time choices when no preference is configured.
- Apply the preference consistently during startup and in-app resume, honor explicit `--cd` overrides, and reject unsupported or unavailable directory choices with a clear error.

## Testing

- Cover configuration parsing and persistence, prompt behavior, startup and in-app flows, session metadata fallback, and remote workspace constraints.

GitOrigin-RevId: 7bf144c4b8c55bdb868cc8a71caf397c7740451a
This commit is contained in:
Anais Killian
2026-07-18 03:58:20 +00:00
committed by copyberry
parent 82b294c73c
commit 2895d82b5e
21 changed files with 1469 additions and 79 deletions
+24
View File
@@ -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<SessionPickerViewMode>,
/// 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<ResumeCwdMode>,
/// Keybinding overrides for the TUI.
///
/// This supports rebinding selected actions globally and by context.
+28
View File
@@ -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": [
{
+45
View File
@@ -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::<ConfigToml>(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#"
+9
View File
@@ -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<I>(mut self, edits: I) -> Self
where
I: IntoIterator<Item = ConfigEdit>,
+6
View File
@@ -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<ResumeCwdMode>,
/// 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
@@ -227,6 +227,7 @@ fn new_config(model: Option<String>, 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],
+3
View File
@@ -512,6 +512,7 @@ pub(crate) struct App {
workspace_command_runner: Option<WorkspaceCommandRunner>,
/// Config is stored here so we can recreate ChatWidgets as needed.
pub(crate) config: Config,
launch_cwd: PathBuf,
pub(crate) state_db: Option<StateDbHandle>,
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,
+62 -12
View File
@@ -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(),
&current_cwd,
target_session.thread_id,
target_session.path.as_deref(),
&target_session,
CwdPromptAction::Resume,
/*allow_prompt*/ true,
crate::session_resume::ResumeCwdContext {
current_cwd: &current_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(&current_cwd, resume_cwd)
.rebuild_config_for_resume_or_fallback(&config_current_cwd, config_resume_cwd)
.await
{
Ok(cfg) => cfg,
+1
View File
@@ -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(),
+432
View File
@@ -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::<serde_json::Value>)
.collect::<std::result::Result<Vec<_>, _>>()?;
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::<Vec<_>>()
.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::<serde_json::Value>)
.collect::<std::result::Result<Vec<_>, _>>()?;
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::<Vec<_>>()
.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;
+26
View File
@@ -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();
+247 -7
View File
@@ -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<ResumeCwdMode> {
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<CwdPromptOutcome> {
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<Line<'static>> {
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<CwdSelection>,
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::<Vec<_>>()
.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::<Vec<_>>()
.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(())
}
}
+278 -45
View File
@@ -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<Option<AbsolutePathBuf>> {
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<ResolveCwdOutcome> {
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(),
&current_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(
+257 -15
View File
@@ -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<Value>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ResolveCwdOutcome {
Continue(Option<PathBuf>),
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<ResumeCwdMode>,
}
pub(crate) fn effective_resume_cwd_mode(
configured_mode: Option<ResumeCwdMode>,
cwd_override: Option<&Path>,
) -> Option<ResumeCwdMode> {
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<ResolveCwdOutcome> {
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: &current_cwd,
remembered_current_cwd: &current_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: &current_cwd,
remembered_current_cwd: &current_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(())
}
}
@@ -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
@@ -0,0 +1,5 @@
---
source: tui/src/app/tests.rs
expression: rendered
---
■ `tui.resume_cwd = "current"` requires `--cd` when using a remote workspace
@@ -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
@@ -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
@@ -0,0 +1,5 @@
---
source: tui/src/cwd_prompt.rs
expression: rendered
---
Failed to save working directory preference.
@@ -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
@@ -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