diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 241c249ce2..1444a55cf3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2340,35 +2340,94 @@ impl Session { ); } + /// Re-derives cwd-dependent fields in `session_configuration` when the working directory + /// has changed. This reloads project-level instructions (e.g. AGENTS.md, CLAUDE.md) from + /// the new directory and invalidates cached skills and plugins so they are rediscovered on + /// the next turn. + /// + /// No-ops when the cwd has not actually changed. + /// + /// Callers must persist the updated `session_configuration` back into `self.state` after + /// this returns — this method intentionally does not acquire the state lock so that the + /// caller can batch multiple mutations before committing. + async fn refresh_session_configuration_for_cwd( + &self, + previous_cwd: &AbsolutePathBuf, + session_configuration: &mut SessionConfiguration, + ) { + if previous_cwd.as_path() == session_configuration.cwd.as_path() { + return; + } + + let mut config = (*session_configuration.original_config_do_not_use).clone(); + config.cwd = session_configuration.cwd.clone(); + session_configuration.user_instructions = get_user_instructions(&config).await; + session_configuration.original_config_do_not_use = Arc::new(config); + self.services.skills_manager.clear_cache(); + self.services.plugins_manager.clear_cache(); + } + + /// Persists the updated session context baseline after a mid-session configuration change + /// (for example a cwd override) without emitting model-visible diff items immediately. + /// + /// The next real user turn is still responsible for diffing against the prior in-memory + /// reference context item and emitting any resulting settings updates into history. + async fn materialize_session_configuration_override( + &self, + session_configuration: &SessionConfiguration, + ) { + let turn_context = self + .new_turn_from_configuration( + self.next_internal_sub_id(), + session_configuration.clone(), + /*final_output_json_schema*/ None, + /*sandbox_policy_changed*/ false, + ) + .await; + + self.persist_rollout_items(&[RolloutItem::TurnContext( + turn_context.to_turn_context_item(), + )]) + .await; + } + pub(crate) async fn update_settings( &self, updates: SessionSettingsUpdate, ) -> ConstraintResult<()> { - let mut state = self.state.lock().await; - - match state.session_configuration.apply(&updates) { - Ok(updated) => { - let previous_cwd = state.session_configuration.cwd.clone(); - let next_cwd = updated.cwd.clone(); - let codex_home = updated.codex_home.clone(); - let session_source = updated.session_source.clone(); - state.session_configuration = updated; - drop(state); - - self.maybe_refresh_shell_snapshot_for_cwd( - &previous_cwd, - &next_cwd, - &codex_home, - &session_source, - ); - - Ok(()) - } - Err(err) => { - warn!("rejected session settings update: {err}"); - Err(err) + let (mut session_configuration, previous_cwd, codex_home, session_source) = { + let state = self.state.lock().await; + match state.session_configuration.apply(&updates) { + Ok(updated) => ( + updated, + state.session_configuration.cwd.clone(), + state.session_configuration.codex_home.clone(), + state.session_configuration.session_source.clone(), + ), + Err(err) => { + warn!("rejected session settings update: {err}"); + return Err(err); + } } + }; + + self.refresh_session_configuration_for_cwd(&previous_cwd, &mut session_configuration) + .await; + { + let mut state = self.state.lock().await; + state.session_configuration = session_configuration.clone(); } + + self.maybe_refresh_shell_snapshot_for_cwd( + &previous_cwd, + &session_configuration.cwd, + &codex_home, + &session_source, + ); + self.materialize_session_configuration_override(&session_configuration) + .await; + + Ok(()) } pub(crate) async fn new_turn_with_sub_id( @@ -2377,7 +2436,7 @@ impl Session { updates: SessionSettingsUpdate, ) -> ConstraintResult> { let ( - session_configuration, + mut session_configuration, sandbox_policy_changed, previous_cwd, codex_home, @@ -2415,6 +2474,13 @@ impl Session { } }; + self.refresh_session_configuration_for_cwd(&previous_cwd, &mut session_configuration) + .await; + { + let mut state = self.state.lock().await; + state.session_configuration = session_configuration.clone(); + } + self.maybe_refresh_shell_snapshot_for_cwd( &previous_cwd, &session_configuration.cwd, diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a394a1914e..903673c67b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2455,7 +2455,7 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ } #[tokio::test] -async fn session_update_settings_keeps_runtime_cwds_absolute() { +async fn session_update_settings_updates_runtime_config_cwds() { let (session, turn_context) = make_session_and_context().await; let updated_cwd = turn_context .cwd @@ -2479,11 +2479,71 @@ async fn session_update_settings_keeps_runtime_cwds_absolute() { let next_turn = session.new_default_turn().await; assert_eq!(session_cwd, updated_cwd); - assert_eq!(config.cwd, turn_context.cwd); + assert_eq!(config.cwd, updated_cwd); assert_eq!(next_turn.cwd, updated_cwd); assert_eq!(next_turn.config.cwd, updated_cwd); } +#[tokio::test] +async fn session_update_settings_refreshes_user_instructions_for_new_cwd() { + let (session, _turn_context) = make_session_and_context().await; + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let cwd_one = + AbsolutePathBuf::try_from(temp_dir.path().join("agents_one")).expect("resolve agents_one"); + let cwd_two = + AbsolutePathBuf::try_from(temp_dir.path().join("agents_two")).expect("resolve agents_two"); + std::fs::create_dir_all(cwd_one.as_path()).expect("create agents_one"); + std::fs::create_dir_all(cwd_two.as_path()).expect("create agents_two"); + std::fs::write( + cwd_one.as_path().join("AGENTS.md"), + "# AGENTS one\n\n\nTurn one instructions.\n\n", + ) + .expect("write AGENTS one"); + std::fs::write( + cwd_two.as_path().join("AGENTS.md"), + "# AGENTS two\n\n\nTurn two instructions.\n\n", + ) + .expect("write AGENTS two"); + + session + .update_settings(SessionSettingsUpdate { + cwd: Some(cwd_one.to_path_buf()), + ..Default::default() + }) + .await + .expect("first cwd update should succeed"); + let first_turn = session.new_default_turn().await; + + assert_eq!(first_turn.cwd, cwd_one); + assert!( + first_turn + .user_instructions + .as_deref() + .is_some_and(|instructions| instructions.contains("Turn one instructions.")), + "expected refreshed instructions to include agents_one content" + ); + + session + .update_settings(SessionSettingsUpdate { + cwd: Some(cwd_two.to_path_buf()), + ..Default::default() + }) + .await + .expect("second cwd update should succeed"); + let config = session.get_config().await; + let second_turn = session.new_default_turn().await; + + assert_eq!(config.cwd, cwd_two); + assert_eq!(second_turn.cwd, cwd_two); + assert!( + second_turn + .user_instructions + .as_deref() + .is_some_and(|instructions| instructions.contains("Turn two instructions.")), + "expected refreshed instructions to include agents_two content" + ); +} + #[tokio::test] async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let codex_home = tempfile::tempdir().expect("create temp dir"); diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 34fd41c083..beeedcd69d 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -1,6 +1,7 @@ use crate::codex::PreviousTurnSettings; use crate::codex::TurnContext; use crate::environment_context::EnvironmentContext; +use crate::instructions::UserInstructions; use crate::shell::Shell; use codex_execpolicy::Policy; use codex_features::Feature; @@ -28,6 +29,24 @@ fn build_environment_update_item( )) } +/// Produces a user-visible `UserInstructions` item when project-level instructions (AGENTS.md, +/// CLAUDE.md) differ between the previous turn context and the current one — typically after +/// a cwd change that lands in a directory with different project docs. +fn build_user_instructions_update_item( + previous: Option<&TurnContextItem>, + next: &TurnContext, +) -> Option { + let prev = previous?; + if prev.user_instructions == next.user_instructions { + return None; + } + + Some(ResponseItem::from(UserInstructions { + directory: next.cwd.to_string_lossy().into_owned(), + text: next.user_instructions.clone().unwrap_or_default(), + })) +} + fn build_permissions_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, @@ -67,6 +86,24 @@ fn build_collaboration_mode_update_item( } } +/// Produces a developer-role instructions item when the base developer instructions have +/// changed between turns — for example because the new cwd's config layers specify different +/// model-level system instructions. +fn build_developer_instructions_update_item( + previous: Option<&TurnContextItem>, + next: &TurnContext, +) -> Option { + let prev = previous?; + if prev.developer_instructions == next.developer_instructions { + return None; + } + + next.developer_instructions + .as_ref() + .cloned() + .map(DeveloperInstructions::new) +} + pub(crate) fn build_realtime_update_item( previous: Option<&TurnContextItem>, previous_turn_settings: Option<&PreviousTurnSettings>, @@ -197,11 +234,15 @@ pub(crate) fn build_settings_update_items( // model-visible item emitted by build_initial_context. Persist the remaining // inputs or add explicit replay events so fork/resume can diff everything // deterministically. - let contextual_user_message = build_environment_update_item(previous, next, shell); + let contextual_user_sections = [ + build_user_instructions_update_item(previous, next), + build_environment_update_item(previous, next, shell), + ]; let developer_update_sections = [ // Keep model-switch instructions first so model-specific guidance is read before // any other context diffs on this turn. build_model_instructions_update_item(previous_turn_settings, next), + build_developer_instructions_update_item(previous, next), build_permissions_update_item(previous, next, exec_policy), build_collaboration_mode_update_item(previous, next), build_realtime_update_item(previous, previous_turn_settings, next), @@ -216,8 +257,6 @@ pub(crate) fn build_settings_update_items( if let Some(developer_message) = build_developer_update_item(developer_update_sections) { items.push(developer_message); } - if let Some(contextual_user_message) = contextual_user_message { - items.push(contextual_user_message); - } + items.extend(contextual_user_sections.into_iter().flatten()); items } diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..5e5296223f 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -177,9 +177,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Diff `user_instructions` and emit updates when AGENTS.md content changes -// (for example after cwd changes), then update this test to assert refreshed AGENTS content. -async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> Result<()> { +async fn snapshot_model_visible_layout_cwd_change_refreshes_agents() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -267,18 +265,18 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R assert_eq!(requests.len(), 2, "expected two requests"); assert_eq!( user_instructions_wrapper_count(&requests[0]), - 0, - "expected first request to omit the serialized user-instructions wrapper when cwd-only project docs are introduced after session init" + 1, + "expected first request to include the serialized user-instructions wrapper for the cwd-specific AGENTS.md" ); assert_eq!( user_instructions_wrapper_count(&requests[1]), - 0, - "expected second request to keep omitting the serialized user-instructions wrapper after cwd change with the current session-scoped project doc behavior" + 2, + "expected second request to include both the prior baseline AGENTS.md wrapper and the refreshed wrapper for the new cwd" ); insta::assert_snapshot!( - "model_visible_layout_cwd_change_does_not_refresh_agents", + "model_visible_layout_cwd_change_refreshes_agents", format_labeled_requests_snapshot( - "Second turn changes cwd to a directory with different AGENTS.md; current behavior does not emit refreshed AGENTS instructions.", + "Second turn changes cwd to a directory with different AGENTS.md and emits refreshed AGENTS instructions.", &[ ("First Request (agents_one)", &requests[0]), ("Second Request (agents_two cwd)", &requests[1]), diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_does_not_refresh_agents.snap b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_refreshes_agents.snap similarity index 50% rename from codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_does_not_refresh_agents.snap rename to codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_refreshes_agents.snap index 9efdd98f77..c640b2747d 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_does_not_refresh_agents.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_cwd_change_refreshes_agents.snap @@ -1,22 +1,28 @@ --- source: core/tests/suite/model_visible_layout.rs -expression: "format_labeled_requests_snapshot(\"Second turn changes cwd to a directory with different AGENTS.md; current behavior does not emit refreshed AGENTS instructions.\",\n&[(\"First Request (agents_one)\", &requests[0]),\n(\"Second Request (agents_two cwd)\", &requests[1]),])" +assertion_line: 276 +expression: "format_labeled_requests_snapshot(\"Second turn changes cwd to a directory with different AGENTS.md and emits refreshed AGENTS instructions.\",\n&[(\"First Request (agents_one)\", &requests[0]),\n(\"Second Request (agents_two cwd)\", &requests[1]),])" --- -Scenario: Second turn changes cwd to a directory with different AGENTS.md; current behavior does not emit refreshed AGENTS instructions. +Scenario: Second turn changes cwd to a directory with different AGENTS.md and emits refreshed AGENTS instructions. ## First Request (agents_one) 00:message/developer[2]: [01] [02] -01:message/user:> +01:message/user[2]: + [01] + [02] > 02:message/user:first turn in agents_one ## Second Request (agents_two cwd) 00:message/developer[2]: [01] [02] -01:message/user:> +01:message/user[2]: + [01] + [02] > 02:message/user:first turn in agents_one 03:message/assistant:turn one complete -04:message/user:> -05:message/user:second turn in agents_two +04:message/user: +05:message/user:> +06:message/user:second turn in agents_two diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..cdf68d9515 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1028,6 +1028,13 @@ fn normalize_harness_overrides_for_cwd( Ok(overrides) } +/// Resolve a user-supplied path argument against the current working directory, expanding +/// `~` to the user's home directory. The result is an absolute path suitable for +/// `std::fs::canonicalize`. +fn resolve_cwd_change_path(path: &str, current_cwd: &Path) -> std::io::Result { + Ok(AbsolutePathBuf::resolve_path_against_base(path.trim(), current_cwd)?.into_path_buf()) +} + fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option { let TypedRequestError::Server { source, .. } = error else { return None; @@ -3435,10 +3442,19 @@ impl App { fn fresh_session_config(&self) -> Config { let mut config = self.config.clone(); - config.service_tier = self.chat_widget.current_service_tier(); + self.apply_runtime_thread_selections(&mut config); config } + /// Stamp the user's current UI-level selections (model, reasoning effort, personality, + /// service tier) onto a `Config` so it matches what the user sees in the TUI. + fn apply_runtime_thread_selections(&self, config: &mut Config) { + config.model = Some(self.chat_widget.current_model().to_string()); + config.model_reasoning_effort = self.chat_widget.current_reasoning_effort(); + config.personality = self.chat_widget.config_ref().personality; + config.service_tier = self.chat_widget.current_service_tier(); + } + async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> { let Some(mut rx) = self.active_thread_rx.take() else { return Ok(()); @@ -4267,6 +4283,76 @@ impl App { AppEvent::CodexOp(op) => { self.submit_active_thread_op(app_server, op.into()).await?; } + AppEvent::ChangeCwd { path } => { + let current_cwd = self.config.cwd.to_path_buf(); + let resolved = match resolve_cwd_change_path(&path, current_cwd.as_path()) { + Ok(resolved) => resolved, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to change directory: {err}")); + return Ok(AppRunControl::Continue); + } + }; + let resolved = match std::fs::canonicalize(&resolved) { + Ok(resolved) => resolved, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to change directory: {err}")); + return Ok(AppRunControl::Continue); + } + }; + if !resolved.is_dir() { + self.chat_widget.add_error_message(format!( + "Path is not a directory: {}", + resolved.display() + )); + return Ok(AppRunControl::Continue); + } + if resolved == current_cwd { + self.chat_widget.add_info_message( + format!("Already in {}", resolved.display()), + /*hint*/ None, + ); + return Ok(AppRunControl::Continue); + } + + let mut config = match self.rebuild_config_for_cwd(resolved.clone()).await { + Ok(config) => config, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to rebuild config: {err}")); + return Ok(AppRunControl::Continue); + } + }; + self.apply_runtime_policy_overrides(&mut config); + self.apply_runtime_thread_selections(&mut config); + self.config = config.clone(); + self.file_search.update_search_dir(resolved.clone()); + self.chat_widget.set_cwd(&config); + self.chat_widget.add_info_message( + format!("Changed working directory to {}", resolved.display()), + /*hint*/ None, + ); + self.app_event_tx.send(AppEvent::CodexOp( + AppCommand::override_turn_context( + Some(resolved), + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*sandbox_policy*/ None, + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ) + .into_core(), + )); + self.app_event_tx.send(AppEvent::CodexOp( + AppCommand::list_skills(Vec::new(), /*force_reload*/ true).into_core(), + )); + } AppEvent::SubmitThreadOp { thread_id, op } => { self.submit_thread_op(app_server, thread_id, op.into()) .await?; @@ -6317,6 +6403,16 @@ mod tests { Ok(()) } + #[test] + fn resolve_cwd_change_path_expands_home_directory() -> Result<()> { + let Some(home) = dirs::home_dir() else { + return Ok(()); + }; + let resolved = resolve_cwd_change_path("~/code", Path::new("/tmp"))?; + assert_eq!(resolved, home.join("code")); + Ok(()) + } + #[test] fn mcp_inventory_maps_prefix_tool_names_by_server() { let statuses = vec![ diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index d727963f0d..91114d90d3 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -124,6 +124,11 @@ pub(crate) enum AppEvent { /// bubbling channels through layers of widgets. CodexOp(Op), + /// Change the running session's working directory. + ChangeCwd { + path: String, + }, + /// Kick off an asynchronous file search for the given query (text after /// the `@`). Previous searches may be cancelled by the app layer so there /// is at most one in-flight search. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6cf6c18d32..2652f0754d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -6287,7 +6287,7 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "/compact "); + assert_eq!(composer.textarea.text(), "/cwd "); assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..d0f79ec412 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5063,6 +5063,9 @@ impl ChatWidget { SlashCommand::Model => { self.open_model_popup(); } + SlashCommand::Cwd => { + self.add_error_message("Usage: /cwd ".to_string()); + } SlashCommand::Fast => { let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { None @@ -5410,6 +5413,18 @@ impl ChatWidget { self.app_event_tx.set_thread_name(name); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::Cwd if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.app_event_tx.send(AppEvent::ChangeCwd { + path: prepared_args, + }); + self.bottom_pane.drain_pending_submission_state(); + } SlashCommand::Plan if !trimmed.is_empty() => { self.dispatch_command(cmd); if self.active_mode_kind() != ModeKind::Plan { @@ -9514,6 +9529,25 @@ impl ChatWidget { self.refresh_model_dependent_surfaces(); } + /// Reset all cwd-dependent widget state after the working directory has changed. + /// This invalidates the git branch cache, project root name, and plugin/skill caches + /// so they are lazily re-derived on the next render cycle. + pub(crate) fn set_cwd(&mut self, config: &Config) { + self.config = config.clone(); + self.current_cwd = Some(config.cwd.to_path_buf()); + self.status_line_project_root_name_cache = None; + self.status_line_branch = None; + self.status_line_branch_cwd = None; + self.status_line_branch_pending = false; + self.status_line_branch_lookup_complete = false; + self.refresh_model_dependent_surfaces(); + self.sync_fast_command_enabled(); + self.sync_personality_command_enabled(); + self.sync_plugins_command_enabled(); + self.refresh_plugin_mentions(); + self.request_redraw(); + } + fn set_service_tier_selection(&mut self, service_tier: Option) { self.set_service_tier(service_tier); self.app_event_tx.send(AppEvent::CodexOp( diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..8822776329 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -88,6 +88,32 @@ async fn slash_quit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +#[tokio::test] +async fn slash_cwd_requires_inline_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Cwd); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one error message"); + let rendered = lines_to_single_string(&cells[0]); + assert!(rendered.contains("Usage: /cwd ")); +} + +#[tokio::test] +async fn slash_cwd_dispatches_change_event_with_args() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.bottom_pane + .set_composer_text("/cwd ../other".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::ChangeCwd { path }) if path == "../other" + ); +} + #[tokio::test] async fn slash_copy_state_tracks_turn_complete_final_reply() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb9..af91edfd90 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -13,6 +13,7 @@ pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. Model, + Cwd, Fast, Approvals, Permissions, @@ -95,6 +96,7 @@ impl SlashCommand { SlashCommand::MemoryDrop => "DO NOT USE", SlashCommand::MemoryUpdate => "DO NOT USE", SlashCommand::Model => "choose what model and reasoning effort to use", + SlashCommand::Cwd => "change the current working directory", SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", SlashCommand::Personality => "choose a communication style for Codex", SlashCommand::Realtime => "toggle realtime voice mode (experimental)", @@ -130,6 +132,7 @@ impl SlashCommand { self, SlashCommand::Review | SlashCommand::Rename + | SlashCommand::Cwd | SlashCommand::Plan | SlashCommand::Fast | SlashCommand::SandboxReadRoot @@ -144,6 +147,7 @@ impl SlashCommand { | SlashCommand::Fork | SlashCommand::Init | SlashCommand::Compact + | SlashCommand::Cwd // | SlashCommand::Undo | SlashCommand::Model | SlashCommand::Fast diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index 0de0f4e023..aed2fbd8f5 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -22,7 +22,11 @@ use ts_rs::TS; pub struct AbsolutePathBuf(PathBuf); impl AbsolutePathBuf { - fn maybe_expand_home_directory(path: &Path) -> PathBuf { + /// Expand a leading `~` (or `~\` on Windows) to the current user's home directory. + /// Does not expand `~otheruser` — only bare `~` is recognized. Paths without a leading + /// tilde are returned unchanged. + fn maybe_expand_home_directory>(path: P) -> PathBuf { + let path = path.as_ref(); if let Some(path_str) = path.to_str() && let Some(home) = home_dir() && let Some(rest) = path_str.strip_prefix('~') @@ -44,13 +48,13 @@ impl AbsolutePathBuf { path: P, base_path: B, ) -> std::io::Result { - let expanded = Self::maybe_expand_home_directory(path.as_ref()); + let expanded = Self::maybe_expand_home_directory(path); let absolute_path = expanded.absolutize_from(base_path.as_ref())?; Ok(Self(absolute_path.into_owned())) } pub fn from_absolute_path>(path: P) -> std::io::Result { - let expanded = Self::maybe_expand_home_directory(path.as_ref()); + let expanded = Self::maybe_expand_home_directory(path); let absolute_path = expanded.absolutize()?; Ok(Self(absolute_path.into_owned())) } @@ -267,6 +271,25 @@ mod tests { assert_eq!(abs_path_buf.as_path(), home.as_path()); } + #[test] + fn maybe_expand_home_directory_expands_root() { + let Some(home) = home_dir() else { + return; + }; + assert_eq!(AbsolutePathBuf::maybe_expand_home_directory("~"), home); + } + + #[test] + fn maybe_expand_home_directory_expands_subpath() { + let Some(home) = home_dir() else { + return; + }; + assert_eq!( + AbsolutePathBuf::maybe_expand_home_directory("~/code"), + home.join("code") + ); + } + #[test] fn home_directory_subpath_is_expanded_in_deserialization() { let Some(home) = home_dir() else {