Ask for cwd choice when resuming session from different cwd (#9731)

# Summary
- Fix resume/fork config rebuild so cwd changes inside the TUI produce a
fully rebuilt Config (trust/approval/sandbox) instead of mutating only
the cwd.
- Preserve `--add-dir` behavior across resume/fork by normalizing
relative roots to absolute paths once (based on the original cwd).
- Prefer latest `TurnContext.cwd` for resume/fork prompts but fall back
to `SessionMeta.cwd` if the latest cwd no longer exists.
- Align resume/fork selection handling and ensure UI config matches the
resumed thread config.
- Fix Windows test TOML path escaping in trust-level test.

# Details
- Rebuild Config via `ConfigBuilder` when resuming into a different cwd;
carry forward runtime approval/sandbox overrides.
- Add `normalize_harness_overrides_for_cwd` to resolve relative
`additional_writable_roots` against the initial cwd before reuse.
- Guard `read_session_cwd` with filesystem existence check for the
latest `TurnContext.cwd`.
- Update naming/flow around cwd comparison and prompt selection.

<img width="603" height="150" alt="Screenshot 2026-01-23 at 5 42 13 PM"
src="https://github.com/user-attachments/assets/d1897386-bb28-4e8a-98cf-187fdebbecb0"
/>

And proof the model understands the new cwd:

<img width="828" height="353" alt="Screenshot 2026-01-22 at 5 36 45 PM"
src="https://github.com/user-attachments/assets/12aed8ca-dec3-4b64-8dae-c6b8cff78387"
/>
This commit is contained in:
Charley Cunningham
2026-01-24 21:57:19 -08:00
committed by GitHub
parent 182000999c
commit 18acec09df
5 changed files with 733 additions and 16 deletions

View File

@@ -12,6 +12,7 @@ use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::chatwidget::ChatWidget;
use crate::chatwidget::ExternalEditorState;
use crate::cwd_prompt::CwdPromptAction;
use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::external_editor;
@@ -36,6 +37,8 @@ use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config_loader::ConfigLayerStackOrdering;
@@ -44,12 +47,14 @@ use codex_core::features::Feature;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::DeprecationNoticeEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::FinalOutput;
use codex_core::protocol::ListSkillsResponseEvent;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::SessionSource;
use codex_core::protocol::SkillErrorInfo;
use codex_core::protocol::TokenUsage;
@@ -60,6 +65,7 @@ use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use crossterm::event::KeyCode;
@@ -87,6 +93,7 @@ use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::unbounded_channel;
use toml::Value as TomlValue;
const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 1024;
@@ -498,6 +505,10 @@ pub(crate) struct App {
/// Config is stored here so we can recreate ChatWidgets as needed.
pub(crate) config: Config,
pub(crate) active_profile: Option<String>,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
runtime_approval_policy_override: Option<AskForApproval>,
runtime_sandbox_policy_override: Option<SandboxPolicy>,
pub(crate) file_search: FileSearchManager,
@@ -545,6 +556,23 @@ struct WindowsSandboxState {
skip_world_writable_scan_once: bool,
}
fn normalize_harness_overrides_for_cwd(
mut overrides: ConfigOverrides,
base_cwd: &Path,
) -> Result<ConfigOverrides> {
if overrides.additional_writable_roots.is_empty() {
return Ok(overrides);
}
let mut normalized = Vec::with_capacity(overrides.additional_writable_roots.len());
for root in overrides.additional_writable_roots.drain(..) {
let absolute = AbsolutePathBuf::resolve_path_against_base(root, base_cwd)?;
normalized.push(absolute.into_path_buf());
}
overrides.additional_writable_roots = normalized;
Ok(overrides)
}
impl App {
pub fn chatwidget_init_for_forked_or_resumed_thread(
&self,
@@ -567,6 +595,38 @@ impl App {
}
}
async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result<Config> {
let mut overrides = self.harness_overrides.clone();
overrides.cwd = Some(cwd.clone());
let cwd_display = cwd.display().to_string();
ConfigBuilder::default()
.codex_home(self.config.codex_home.clone())
.cli_overrides(self.cli_kv_overrides.clone())
.harness_overrides(overrides)
.build()
.await
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
}
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
&& let Err(err) = config.approval_policy.set(*policy)
{
tracing::warn!(%err, "failed to carry forward approval policy override");
self.chat_widget.add_error_message(format!(
"Failed to carry forward approval policy override: {err}"
));
}
if let Some(policy) = self.runtime_sandbox_policy_override.as_ref()
&& let Err(err) = config.sandbox_policy.set(policy.clone())
{
tracing::warn!(%err, "failed to carry forward sandbox policy override");
self.chat_widget.add_error_message(format!(
"Failed to carry forward sandbox policy override: {err}"
));
}
}
async fn shutdown_current_thread(&mut self) {
if let Some(thread_id) = self.chat_widget.thread_id() {
// Clear any in-flight rollback guard when switching threads.
@@ -824,6 +884,8 @@ impl App {
tui: &mut tui::Tui,
auth_manager: Arc<AuthManager>,
mut config: Config,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
active_profile: Option<String>,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
@@ -838,6 +900,8 @@ impl App {
emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice);
emit_project_config_warnings(&app_event_tx, &config);
let harness_overrides =
normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?;
let thread_manager = Arc::new(ThreadManager::new(
config.codex_home.clone(),
auth_manager.clone(),
@@ -979,6 +1043,10 @@ impl App {
auth_manager: auth_manager.clone(),
config,
active_profile,
cli_kv_overrides,
harness_overrides,
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
enhanced_keys_supported,
transcript_cells: Vec::new(),
@@ -1203,6 +1271,34 @@ impl App {
.await?
{
SessionSelection::Resume(path) => {
let current_cwd = self.config.cwd.clone();
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
tui,
&current_cwd,
&path,
CwdPromptAction::Resume,
true,
)
.await?
{
Some(cwd) => cwd,
None => current_cwd.clone(),
};
let mut resume_config = if crate::cwds_differ(&current_cwd, &resume_cwd) {
match self.rebuild_config_for_cwd(resume_cwd).await {
Ok(cfg) => cfg,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to rebuild configuration for resume: {err}"
));
return Ok(AppRunControl::Continue);
}
}
} else {
// No rebuild needed: current_cwd comes from self.config.cwd.
self.config.clone()
};
self.apply_runtime_policy_overrides(&mut resume_config);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
@@ -1210,7 +1306,7 @@ impl App {
match self
.server
.resume_thread_from_rollout(
self.config.clone(),
resume_config.clone(),
path.clone(),
self.auth_manager.clone(),
)
@@ -1218,6 +1314,11 @@ impl App {
{
Ok(resumed) => {
self.shutdown_current_thread().await;
self.config = resume_config;
self.file_search = FileSearchManager::new(
self.config.cwd.clone(),
self.app_event_tx.clone(),
);
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
@@ -1660,6 +1761,13 @@ impl App {
}
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
self.runtime_approval_policy_override = Some(policy);
if let Err(err) = self.config.approval_policy.set(policy) {
tracing::warn!(%err, "failed to set approval policy on app config");
self.chat_widget
.add_error_message(format!("Failed to set approval policy: {err}"));
return Ok(AppRunControl::Continue);
}
self.chat_widget.set_approval_policy(policy);
}
AppEvent::UpdateSandboxPolicy(policy) => {
@@ -1688,6 +1796,8 @@ impl App {
.add_error_message(format!("Failed to set sandbox policy: {err}"));
return Ok(AppRunControl::Continue);
}
self.runtime_sandbox_policy_override =
Some(self.config.sandbox_policy.get().clone());
// If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan.
#[cfg(target_os = "windows")]
@@ -2236,6 +2346,7 @@ mod tests {
use codex_core::CodexAuth;
use codex_core::ThreadManager;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Event;
@@ -2254,6 +2365,25 @@ mod tests {
use std::sync::atomic::AtomicBool;
use tempfile::tempdir;
#[test]
fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> {
let temp_dir = tempdir()?;
let base_cwd = temp_dir.path().join("base");
std::fs::create_dir_all(&base_cwd)?;
let overrides = ConfigOverrides {
additional_writable_roots: vec![PathBuf::from("rel")],
..Default::default()
};
let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?;
assert_eq!(
normalized.additional_writable_roots,
vec![base_cwd.join("rel")]
);
Ok(())
}
async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
@@ -2275,6 +2405,10 @@ mod tests {
auth_manager,
config,
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
transcript_cells: Vec::new(),
overlay: None,
@@ -2323,6 +2457,10 @@ mod tests {
auth_manager,
config,
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
transcript_cells: Vec::new(),
overlay: None,

View File

@@ -0,0 +1,286 @@
use std::path::Path;
use crate::key_hint;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use color_eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::WidgetRef;
use tokio_stream::StreamExt;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CwdPromptAction {
Resume,
Fork,
}
impl CwdPromptAction {
fn verb(self) -> &'static str {
match self {
CwdPromptAction::Resume => "resume",
CwdPromptAction::Fork => "fork",
}
}
fn past_participle(self) -> &'static str {
match self {
CwdPromptAction::Resume => "resumed",
CwdPromptAction::Fork => "forked",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CwdSelection {
Current,
Session,
}
impl CwdSelection {
fn next(self) -> Self {
match self {
CwdSelection::Current => CwdSelection::Session,
CwdSelection::Session => CwdSelection::Current,
}
}
fn prev(self) -> Self {
match self {
CwdSelection::Current => CwdSelection::Session,
CwdSelection::Session => CwdSelection::Current,
}
}
}
pub(crate) async fn run_cwd_selection_prompt(
tui: &mut Tui,
action: CwdPromptAction,
current_cwd: &Path,
session_cwd: &Path,
) -> Result<CwdSelection> {
let mut screen = CwdPromptScreen::new(
tui.frame_requester(),
action,
current_cwd.display().to_string(),
session_cwd.display().to_string(),
);
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
})?;
let events = tui.event_stream();
tokio::pin!(events);
while !screen.is_done() {
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
})?;
}
}
} else {
break;
}
}
Ok(screen.selection().unwrap_or(CwdSelection::Session))
}
struct CwdPromptScreen {
request_frame: FrameRequester,
action: CwdPromptAction,
current_cwd: String,
session_cwd: String,
highlighted: CwdSelection,
selection: Option<CwdSelection>,
}
impl CwdPromptScreen {
fn new(
request_frame: FrameRequester,
action: CwdPromptAction,
current_cwd: String,
session_cwd: String,
) -> Self {
Self {
request_frame,
action,
current_cwd,
session_cwd,
highlighted: CwdSelection::Session,
selection: None,
}
}
fn handle_key(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
if key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
{
self.select(CwdSelection::Session);
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::Char('1') => self.select(CwdSelection::Session),
KeyCode::Char('2') => self.select(CwdSelection::Current),
KeyCode::Enter => self.select(self.highlighted),
KeyCode::Esc => self.select(CwdSelection::Session),
_ => {}
}
}
fn set_highlight(&mut self, highlight: CwdSelection) {
if self.highlighted != highlight {
self.highlighted = highlight;
self.request_frame.schedule_frame();
}
}
fn select(&mut self, selection: CwdSelection) {
self.highlighted = selection;
self.selection = Some(selection);
self.request_frame.schedule_frame();
}
fn is_done(&self) -> bool {
self.selection.is_some()
}
fn selection(&self) -> Option<CwdSelection> {
self.selection
}
}
impl WidgetRef for &CwdPromptScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
let action_verb = self.action.verb();
let action_past = self.action.past_participle();
let current_cwd = self.current_cwd.as_str();
let session_cwd = self.session_cwd.as_str();
column.push("");
column.push(Line::from(vec![
"Choose working directory to ".into(),
action_verb.bold(),
" this session".into(),
]));
column.push("");
column.push(
Line::from(format!(
"Session = latest cwd recorded in the {action_past} session"
))
.dim()
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.push(
Line::from("Current = your current working directory".dim())
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.push("");
column.push(selection_option_row(
0,
format!("Use session directory ({session_cwd})"),
self.highlighted == CwdSelection::Session,
));
column.push(selection_option_row(
1,
format!("Use current directory ({current_cwd})"),
self.highlighted == CwdSelection::Current,
));
column.push("");
column.push(
Line::from(vec![
"Press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to continue".dim(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_backend::VT100Backend;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
fn new_prompt() -> CwdPromptScreen {
CwdPromptScreen::new(
FrameRequester::test_dummy(),
CwdPromptAction::Resume,
"/Users/example/current".to_string(),
"/Users/example/session".to_string(),
)
}
#[test]
fn cwd_prompt_snapshot() {
let screen = new_prompt();
let mut terminal = Terminal::new(VT100Backend::new(80, 14)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render cwd prompt");
insta::assert_snapshot!("cwd_prompt_modal", terminal.backend());
}
#[test]
fn cwd_prompt_fork_snapshot() {
let screen = CwdPromptScreen::new(
FrameRequester::test_dummy(),
CwdPromptAction::Fork,
"/Users/example/current".to_string(),
"/Users/example/session".to_string(),
);
let mut terminal = Terminal::new(VT100Backend::new(80, 14)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render cwd prompt");
insta::assert_snapshot!("cwd_prompt_fork_modal", terminal.backend());
}
#[test]
fn cwd_prompt_selects_session_by_default() {
let mut screen = new_prompt();
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(screen.selection(), Some(CwdSelection::Session));
}
#[test]
fn cwd_prompt_can_select_current() {
let mut screen = new_prompt();
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(screen.selection(), Some(CwdSelection::Current));
}
}

View File

@@ -27,13 +27,19 @@ use codex_core::config_loader::ConfigLoadError;
use codex_core::config_loader::format_config_error_with_source;
use codex_core::find_thread_path_by_id_str;
use codex_core::get_platform_sandbox;
use codex_core::path_utils;
use codex_core::protocol::AskForApproval;
use codex_core::read_session_meta_line;
use codex_core::terminal::Multiplexer;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_utils_absolute_path::AbsolutePathBuf;
use cwd_prompt::CwdPromptAction;
use cwd_prompt::CwdSelection;
use std::fs::OpenOptions;
use std::path::Path;
use std::path::PathBuf;
use tracing::error;
use tracing_appender::non_blocking;
@@ -54,6 +60,7 @@ mod collab;
mod collaboration_modes;
mod color;
pub mod custom_terminal;
mod cwd_prompt;
mod diff_render;
mod exec_cell;
mod exec_command;
@@ -577,25 +584,27 @@ async fn run_ratatui_app(
resume_picker::SessionSelection::StartFresh
};
let current_cwd = config.cwd.clone();
let allow_prompt = cli.cwd.is_none();
let action_and_path_if_resume_or_fork = match &session_selection {
resume_picker::SessionSelection::Resume(path) => Some((CwdPromptAction::Resume, path)),
resume_picker::SessionSelection::Fork(path) => Some((CwdPromptAction::Fork, path)),
_ => None,
};
let fallback_cwd = match action_and_path_if_resume_or_fork {
Some((action, path)) => {
resolve_cwd_for_resume_or_fork(&mut tui, &current_cwd, path, action, allow_prompt)
.await?
}
None => None,
};
let config = match &session_selection {
resume_picker::SessionSelection::Resume(path)
| resume_picker::SessionSelection::Fork(path) => {
let history_cwd = match read_session_meta_line(path).await {
Ok(meta_line) => Some(meta_line.meta.cwd),
Err(err) => {
let rollout_path = path.display().to_string();
tracing::warn!(
%rollout_path,
%err,
"Failed to read session metadata from rollout"
);
None
}
};
resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => {
load_config_or_exit_with_fallback_cwd(
cli_kv_overrides.clone(),
overrides.clone(),
history_cwd,
fallback_cwd,
)
.await
}
@@ -618,6 +627,8 @@ async fn run_ratatui_app(
&mut tui,
auth_manager,
config,
cli_kv_overrides.clone(),
overrides.clone(),
active_profile,
prompt,
images,
@@ -635,6 +646,77 @@ async fn run_ratatui_app(
app_result
}
pub(crate) async fn read_session_cwd(path: &Path) -> Option<PathBuf> {
// Prefer the latest TurnContext cwd so resume/fork reflects the most recent
// session directory (for the changed-cwd prompt). The alternative would be
// mutating the SessionMeta line when the session cwd changes, but the rollout
// is an append-only JSONL log and rewriting the head would be error-prone.
// When rollouts move to SQLite, we can drop this scan.
if let Some(cwd) = parse_latest_turn_context_cwd(path).await {
return Some(cwd);
}
match read_session_meta_line(path).await {
Ok(meta_line) => Some(meta_line.meta.cwd),
Err(err) => {
let rollout_path = path.display().to_string();
tracing::warn!(
%rollout_path,
%err,
"Failed to read session metadata from rollout"
);
None
}
}
}
async fn parse_latest_turn_context_cwd(path: &Path) -> Option<PathBuf> {
let text = tokio::fs::read_to_string(path).await.ok()?;
for line in text.lines().rev() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(rollout_line) = serde_json::from_str::<RolloutLine>(trimmed) else {
continue;
};
if let RolloutItem::TurnContext(item) = rollout_line.item {
return Some(item.cwd);
}
}
None
}
pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool {
match (
path_utils::normalize_for_path_comparison(current_cwd),
path_utils::normalize_for_path_comparison(session_cwd),
) {
(Ok(current), Ok(session)) => current != session,
_ => current_cwd != session_cwd,
}
}
pub(crate) async fn resolve_cwd_for_resume_or_fork(
tui: &mut Tui,
current_cwd: &Path,
path: &Path,
action: CwdPromptAction,
allow_prompt: bool,
) -> color_eyre::Result<Option<PathBuf>> {
let Some(history_cwd) = read_session_cwd(path).await else {
return Ok(None);
};
if allow_prompt && cwds_differ(current_cwd, &history_cwd) {
let selection =
cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?;
return Ok(Some(match selection {
CwdSelection::Current => current_cwd.to_path_buf(),
CwdSelection::Session => history_cwd,
}));
}
Ok(Some(history_cwd))
}
#[expect(
clippy::print_stderr,
reason = "TUI should no longer be displayed, so we can write to stderr."
@@ -772,7 +854,14 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool
mod tests {
use super::*;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::ProjectConfig;
use codex_core::protocol::AskForApproval;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::TurnContextItem;
use serial_test::serial;
use tempfile::TempDir;
@@ -846,4 +935,180 @@ mod tests {
);
Ok(())
}
fn build_turn_context(config: &Config, cwd: PathBuf) -> TurnContextItem {
let model = config
.model
.clone()
.unwrap_or_else(|| "gpt-5.1".to_string());
TurnContextItem {
cwd,
approval_policy: config.approval_policy.value(),
sandbox_policy: config.sandbox_policy.get().clone(),
model,
personality: None,
collaboration_mode: None,
effort: config.model_reasoning_effort,
summary: config.model_reasoning_summary,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: None,
}
}
#[tokio::test]
async fn read_session_cwd_prefers_latest_turn_context() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let first = temp_dir.path().join("first");
let second = temp_dir.path().join("second");
std::fs::create_dir_all(&first)?;
std::fs::create_dir_all(&second)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let lines = vec![
RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::TurnContext(build_turn_context(&config, first)),
},
RolloutLine {
timestamp: "t1".to_string(),
item: RolloutItem::TurnContext(build_turn_context(&config, second.clone())),
},
];
let mut text = String::new();
for line in lines {
text.push_str(&serde_json::to_string(&line).expect("serialize rollout"));
text.push('\n');
}
std::fs::write(&rollout_path, text)?;
let cwd = read_session_cwd(&rollout_path).await.expect("expected cwd");
assert_eq!(cwd, second);
Ok(())
}
#[tokio::test]
async fn should_prompt_when_meta_matches_current_but_latest_turn_differs() -> std::io::Result<()>
{
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let current = temp_dir.path().join("current");
let latest = temp_dir.path().join("latest");
std::fs::create_dir_all(&current)?;
std::fs::create_dir_all(&latest)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let session_meta = SessionMeta {
cwd: current.clone(),
..SessionMeta::default()
};
let lines = vec![
RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: session_meta,
git: None,
}),
},
RolloutLine {
timestamp: "t1".to_string(),
item: RolloutItem::TurnContext(build_turn_context(&config, latest.clone())),
},
];
let mut text = String::new();
for line in lines {
text.push_str(&serde_json::to_string(&line).expect("serialize rollout"));
text.push('\n');
}
std::fs::write(&rollout_path, text)?;
let session_cwd = read_session_cwd(&rollout_path).await.expect("expected cwd");
assert_eq!(session_cwd, latest);
assert!(cwds_differ(&current, &session_cwd));
Ok(())
}
#[tokio::test]
async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().to_path_buf();
let trusted = temp_dir.path().join("trusted");
let untrusted = temp_dir.path().join("untrusted");
std::fs::create_dir_all(&trusted)?;
std::fs::create_dir_all(&untrusted)?;
// TOML keys need escaped backslashes on Windows paths.
let trusted_display = trusted.display().to_string().replace('\\', "\\\\");
let untrusted_display = untrusted.display().to_string().replace('\\', "\\\\");
let config_toml = format!(
r#"[projects."{trusted_display}"]
trust_level = "trusted"
[projects."{untrusted_display}"]
trust_level = "untrusted"
"#
);
std::fs::write(temp_dir.path().join("config.toml"), config_toml)?;
let trusted_overrides = ConfigOverrides {
cwd: Some(trusted.clone()),
..Default::default()
};
let trusted_config = ConfigBuilder::default()
.codex_home(codex_home.clone())
.harness_overrides(trusted_overrides.clone())
.build()
.await?;
assert_eq!(
trusted_config.approval_policy.value(),
AskForApproval::OnRequest
);
let untrusted_overrides = ConfigOverrides {
cwd: Some(untrusted),
..trusted_overrides
};
let untrusted_config = ConfigBuilder::default()
.codex_home(codex_home)
.harness_overrides(untrusted_overrides)
.build()
.await?;
assert_eq!(
untrusted_config.approval_policy.value(),
AskForApproval::UnlessTrusted
);
Ok(())
}
#[tokio::test]
async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let _config = build_config(&temp_dir).await?;
let session_cwd = temp_dir.path().join("session");
std::fs::create_dir_all(&session_cwd)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let session_meta = SessionMeta {
cwd: session_cwd.clone(),
..SessionMeta::default()
};
let meta_line = RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: session_meta,
git: None,
}),
};
let text = format!(
"{}\n",
serde_json::to_string(&meta_line).expect("serialize meta")
);
std::fs::write(&rollout_path, text)?;
let cwd = read_session_cwd(&rollout_path).await.expect("expected cwd");
assert_eq!(cwd, session_cwd);
Ok(())
}
}

View File

@@ -0,0 +1,14 @@
---
source: tui/src/cwd_prompt.rs
expression: terminal.backend()
---
Choose working directory to fork this session
Session = latest cwd recorded in the forked session
Current = your current working directory
1. Use session directory (/Users/example/session)
2. Use current directory (/Users/example/current)
Press enter to continue

View File

@@ -0,0 +1,14 @@
---
source: tui/src/cwd_prompt.rs
expression: terminal.backend()
---
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)
Press enter to continue