diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7063f8f5baa..f0ce8b1214b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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, + cli_kv_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, + runtime_approval_policy_override: Option, + runtime_sandbox_policy_override: Option, 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 { + 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 { + 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, mut config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, active_profile: Option, initial_prompt: Option, initial_images: Vec, @@ -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, + ¤t_cwd, + &path, + CwdPromptAction::Resume, + true, + ) + .await? + { + Some(cwd) => cwd, + None => current_cwd.clone(), + }; + let mut resume_config = if crate::cwds_differ(¤t_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, diff --git a/codex-rs/tui/src/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs new file mode 100644 index 00000000000..2a9c016a1ed --- /dev/null +++ b/codex-rs/tui/src/cwd_prompt.rs @@ -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 { + 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, +} + +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 { + 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)); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e3ee2ed4e63..0fac506d320 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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, ¤t_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 { + // 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 { + 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::(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> { + 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(¤t)?; + 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(¤t, &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(()) + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap new file mode 100644 index 00000000000..38b712e872a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap new file mode 100644 index 00000000000..4e87cfdfc7a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap @@ -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