mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
¤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(
|
||||
|
||||
@@ -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: ¤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(())
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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
|
||||
+5
@@ -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
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/cwd_prompt.rs
|
||||
expression: rendered
|
||||
---
|
||||
Failed to save working directory preference.
|
||||
+16
@@ -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
|
||||
+15
@@ -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
|
||||
Reference in New Issue
Block a user