diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a947f240cf..f7c68c9341 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -234,6 +234,7 @@ mod thread_routing; mod thread_session_state; mod thread_settings; mod transcript_export; +mod working_directory; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; @@ -522,6 +523,8 @@ pub(crate) struct App { /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, launch_cwd: PathBuf, + /// Resume anchor selected by `/cd`; ordinary resumes retain the immutable launch cwd. + runtime_working_directory_override: Option, pub(crate) state_db: Option, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 4f14c69070..43f2e4585c 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -232,10 +232,10 @@ impl App { let app_event_tx = self.app_event_tx.clone(); let cwd = self.config.cwd.to_path_buf(); tokio::spawn(async move { - let result = fetch_skills_list(request_handle, cwd) + let result = fetch_skills_list(request_handle, cwd.clone()) .await .map_err(|err| format!("{err:#}")); - app_event_tx.send(AppEvent::SkillsListLoaded { result }); + app_event_tx.send(AppEvent::SkillsListLoaded { cwd, result }); }); } @@ -565,14 +565,15 @@ impl App { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); if !self.config.features.enabled(Feature::Plugins) { - app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None }); + app_event_tx.send(AppEvent::PluginMentionsLoaded { cwd, plugins: None }); return; } tokio::spawn(async move { - match fetch_plugin_mentions(request_handle, cwd).await { + match fetch_plugin_mentions(request_handle, cwd.clone()).await { Ok(plugins) => { app_event_tx.send(AppEvent::PluginMentionsLoaded { + cwd, plugins: Some(plugins), }); } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 1a23f6f06a..9884e89c8a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -10,6 +10,7 @@ use crate::app_server_session::ForkGoalContinuation; use crate::config_update::format_config_error; use crate::external_agent_config_migration::flow::ExternalAgentConfigMigrationFlowOutcome; use crate::pager_overlay::TranscriptHistoryState; +use crate::session_resume::cwds_differ; #[cfg(target_os = "windows")] use codex_config::types::WindowsSandboxModeToml; @@ -23,6 +24,9 @@ impl App { event: AppEvent, ) -> Result { match event { + AppEvent::SkillsListLoaded { ref cwd, .. } + | AppEvent::PluginMentionsLoaded { ref cwd, .. } + if cwds_differ(cwd, self.config.cwd.as_path()) => {} AppEvent::NewSession { name } => { self.start_fresh_session_with_summary_hint( tui, app_server, /*session_start_source*/ None, @@ -30,6 +34,44 @@ impl App { ) .await; } + AppEvent::ChangeWorkingDirectory { + thread_id, + requested_cwd, + } => { + if self.primary_thread_id != Some(thread_id) + || !self.chat_widget.can_change_working_directory(thread_id) + { + self.chat_widget.add_error_message( + "Changing directories requires an idle primary session without queued input." + .to_string(), + ); + } else if crate::uses_remote_workspace_or_environment( + &self.app_server_target, + self.environment_manager.as_ref(), + ) { + self.chat_widget.add_error_message( + "Changing directories is not supported for remote workspaces or remote execution environments." + .to_string(), + ); + } else { + let cwd = AbsolutePathBuf::resolve_path_against_base( + requested_cwd.as_path(), + self.chat_widget.config_ref().cwd.as_path(), + ); + match std::fs::metadata(cwd.as_path()) { + Ok(metadata) if metadata.is_dir() => { + self.change_working_directory(tui, app_server, cwd).await; + } + Ok(_) => self + .chat_widget + .add_error_message(format!("Not a directory: {}", cwd.display())), + Err(error) => self.chat_widget.add_error_message(format!( + "Cannot access directory {}: {error}", + cwd.display() + )), + } + } + } AppEvent::StartupThreadStarted { result } => { self.handle_startup_thread_started(app_server, result) .await?; @@ -657,7 +699,11 @@ impl App { AppEvent::AppendMessageHistoryEntry { thread_id, text } => { self.append_message_history_entry(thread_id, text); } - AppEvent::SyncThreadGitBranch { thread_id, branch } => { + AppEvent::SyncThreadGitBranch { + thread_id, + branch, + cwd: _cwd, + } => { if let Err(err) = app_server .thread_metadata_update_branch(thread_id, branch) .await @@ -692,7 +738,10 @@ impl App { self.enqueue_thread_history_entry_response(thread_id, event) .await?; } - AppEvent::DiffResult(text) => { + AppEvent::DiffResult(cwd, text) => { + if cwds_differ(&cwd, self.chat_widget.config_ref().cwd.as_path()) { + return Ok(AppRunControl::Continue); + } // Clear the in-progress state in the bottom pane self.chat_widget.on_diff_complete(); // Enter alternate screen using TUI helper and build pager lines @@ -1028,7 +1077,7 @@ impl App { } => { self.handle_mcp_inventory_result(result, detail, thread_id); } - AppEvent::SkillsListLoaded { result } => { + AppEvent::SkillsListLoaded { result, .. } => { self.handle_skills_list_result( result.map_err(|err| color_eyre::eyre::eyre!(err)), "failed to load skills on startup", @@ -1959,7 +2008,7 @@ impl App { AppEvent::RefreshPluginMentions => { self.refresh_plugin_mentions(app_server); } - AppEvent::PluginMentionsLoaded { mut plugins } => { + AppEvent::PluginMentionsLoaded { mut plugins, .. } => { if !self.config.features.enabled(Feature::Plugins) { plugins = None; } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 4edbb5abc6..d255fefcbf 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -922,9 +922,9 @@ impl App { self.refresh_in_memory_config_from_disk_best_effort("resuming a thread") .await; let cwd_override = self - .harness_overrides - .cwd + .runtime_working_directory_override .as_deref() + .or(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, diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 97d5bb6977..5d2a244253 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -396,6 +396,7 @@ See the Codex keymap documentation for supported actions and examples." workspace_command_runner: Some(workspace_command_runner), config, launch_cwd, + runtime_working_directory_override: None, state_db, cli_kv_overrides, harness_overrides, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 45ab3116cc..2c39c9972e 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -23,6 +23,7 @@ pub(super) async fn make_test_app() -> App { chat_widget, workspace_command_runner: None, launch_cwd: config.cwd.to_path_buf(), + runtime_working_directory_override: None, config, state_db: None, cli_kv_overrides: Vec::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 04322ea582..5ce24e40ef 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4867,6 +4867,7 @@ async fn make_test_app() -> App { chat_widget, workspace_command_runner: None, launch_cwd: config.cwd.to_path_buf(), + runtime_working_directory_override: None, config, state_db: None, cli_kv_overrides: Vec::new(), @@ -4942,6 +4943,7 @@ async fn make_test_app_with_channels() -> ( chat_widget, workspace_command_runner: None, launch_cwd: config.cwd.to_path_buf(), + runtime_working_directory_override: None, config, state_db: None, cli_kv_overrides: Vec::new(), @@ -6362,12 +6364,16 @@ async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()> #[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"), + for (configured_mode, has_explicit_cwd, has_remote_exec, has_runtime_cwd, expected_directory) in [ + ("current", false, false, false, "launch"), + ("session", false, false, false, "session"), + ("session", true, false, false, "explicit"), + ("session", false, true, false, "session"), + ("session", true, true, false, "explicit"), + ("current", false, false, true, "runtime"), + ("current", true, false, true, "runtime"), + ("session", false, false, true, "runtime"), + ("session", true, false, true, "runtime"), ] { let temp_dir = tempdir()?; let codex_home = temp_dir.path().join("codex-home"); @@ -6375,15 +6381,25 @@ async fn in_app_resume_uses_configured_or_explicit_cwd() -> Result<()> { let active_cwd = temp_dir.path().join("active"); let session_cwd = temp_dir.path().join("session"); let explicit_cwd = temp_dir.path().join("explicit"); + let runtime_cwd = temp_dir.path().join("runtime"); 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::create_dir_all(&runtime_cwd)?; std::fs::write( codex_home.join("config.toml"), format!("[tui]\nresume_cwd = \"{configured_mode}\"\n"), )?; + if has_runtime_cwd { + crate::legacy_core::config::set_project_trust_level( + codex_home.as_path(), + runtime_cwd.as_path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + } let config = ConfigBuilder::default() .codex_home(codex_home.clone()) .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) @@ -6455,6 +6471,30 @@ async fn in_app_resume_uses_configured_or_explicit_cwd() -> Result<()> { app.chat_widget .handle_thread_session_quiet(test_thread_session(ThreadId::new(), active_cwd)); let mut tui = crate::tui::test_support::make_test_tui()?; + if has_runtime_cwd { + let started = app_server + .start_thread_with_session_start_source( + &app.config, + /*session_start_source*/ None, + ) + .await?; + let empty_thread_id = started.session.thread_id; + app.replace_chat_widget_with_app_server_thread( + &mut tui, + started, + crate::app::session_lifecycle::ThreadAttachPresentation::SessionLineage, + /*initial_user_message*/ None, + ) + .await?; + app.change_working_directory(&mut tui, &mut app_server, runtime_cwd.clone().abs()) + .await; + + assert_ne!(app.chat_widget.thread_id(), Some(empty_thread_id)); + assert_eq!( + app.runtime_working_directory_override.as_deref(), + Some(runtime_cwd.as_path()), + ); + } let control = app .resume_target_session( diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 234275913a..9c5b0659ea 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -109,6 +109,8 @@ async fn start_recording_app_server_with_history( let proxy = tokio::spawn(async move { let (stream, _) = listener.accept().await?; let mut websocket = accept_async(stream).await?; + let mut inventories = usize::from(failed_thread_name == Some("background")); + let mut reject_detach = false; while let Some(frame) = websocket.next().await { let Message::Text(text) = frame? else { continue; @@ -187,6 +189,11 @@ async fn start_recording_app_server_with_history( }, }) } else { + let background = request.method == "thread/backgroundTerminals/list" && { + inventories += usize::from(inventories > 0); + matches!(inventories, 2 | 4) + }; + let detach = request.method == "thread/unsubscribe"; let request = serde_json::from_value::( serde_json::to_value(request)?, )?; @@ -202,7 +209,12 @@ async fn start_recording_app_server_with_history( &request, ClientRequest::ThreadSetName { params, .. } if failed_thread_name == Some(params.name.as_str()) - ); + ) || matches!( + &request, + ClientRequest::ThreadFork { params, .. } + if params.cwd.as_deref().is_some_and(|cwd| cwd.ends_with("failure")) + && { reject_detach = true; true } + ) || (detach && std::mem::take(&mut reject_detach)); if force_failure { JSONRPCMessage::Error(JSONRPCError { id: request_id, @@ -213,7 +225,12 @@ async fn start_recording_app_server_with_history( }, }) } else { - match embedded.request(request).await? { + let mut result = embedded.request(request).await?; + if background { + let terminal = r#"{"data":[{"itemId":"x","processId":"x","command":"x","cwd":"/"}],"nextCursor":null}"#; + result = Ok(serde_json::from_str(terminal)?); + } + match result { Ok(result) => JSONRPCMessage::Response(JSONRPCResponse { id: request_id, result, @@ -1313,6 +1330,254 @@ async fn cold_paginated_subagent_transcript_excludes_inherited_parent_history() Ok(()) } +#[tokio::test] +async fn changing_directory_preserves_project_trust_permissions_history_and_hooks() -> Result<()> { + use codex_protocol::config_types::TrustLevel as T; + use serde_json::json; + use std::fs; + + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + app.harness_overrides.permission_profile = Some(PermissionProfile::workspace_write()); + let names = ["root", "trusted", "unknown", "untrusted", "p", "failure"]; + let [current, trusted, unknown, untrusted, mismatch, failed] = + names.map(|name| codex_home.path().join(name)); + fs::create_dir_all(¤t)?; + for directory in [&trusted, &unknown, &untrusted, &mismatch, &failed] { + fs::create_dir_all(directory.join(".codex"))?; + fs::write(directory.join(".codex/config.toml"), "")?; + } + let contents = "developer_instructions = \"destination policy\"\nmodel_reasoning_effort = \"high\"\napproval_policy = \"on-request\"\n[tui]\ntheme = \"dracula\"\n[tui.keymap.global]\nopen_transcript = \"f12\""; + fs::write(trusted.join(".codex/config.toml"), contents)?; + let agents = trusted.join("AGENTS.md"); + fs::write(&agents, "Follow destination project instructions.")?; + let hooks = r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"x"}]}]}}"#; + fs::write(trusted.join(".codex/hooks.json"), hooks)?; + let contents = "default_permissions = \"dev\"\n[permissions.dev.filesystem]\n\":root\" = \"write\"\n[tui.keymap.global]\nopen_transcript = \"ctrl-l\""; + fs::write(mismatch.join(".codex/config.toml"), contents)?; + let requirements = codex_home.path().join("requirements.toml"); + let rules = "allowed_approval_policies=[\"untrusted\"]\nallowed_sandbox_modes=[\"read-only\"]"; + fs::write(&requirements, rules)?; + fs::create_dir_all(unknown.join(".git"))?; + for dir in [&trusted, &untrusted, &mismatch, &failed] { + let trust = [T::Trusted, T::Untrusted][usize::from(dir == &untrusted)]; + crate::legacy_core::config::set_project_trust_level(codex_home.path(), dir, trust) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + } + app.config.cwd = current.clone().abs(); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(ThreadId::new(), current.clone())); + let (no_list, nick, role, runtime, background) = (None, None, None, None, Some("background")); + let (mut server, requests, proxy) = + start_recording_app_server(&app.config, no_list, background).await?; + let (rec, plain, req) = (recorded_params, crate::key_hint::plain, &requests); + let mut tui = crate::tui::test_support::make_test_tui()?; + let (source, message, name) = (None, None, Some("Previous project".to_string())); + app.start_fresh_session_with_summary_hint(&mut tui, &mut server, source, message, name) + .await; + let original = app.chat_widget.thread_id().expect("original thread"); + let rollout = app.chat_widget.rollout_path().expect("original rollout"); + let json = r#"{"type":"message","role":"assistant","content":[{"type":"output_text","text":"saved history"}]}"#; + let item = serde_json::from_str(json)?; + server.thread_inject_items(original, vec![item]).await?; + let tracked = server.start_thread(&app.config).await?; + let (child, capacity) = (tracked.session.thread_id, THREAD_EVENT_CHANNEL_CAPACITY); + let channel = ThreadEventChannel::new_with_session(capacity, tracked.session, tracked.turns); + app.thread_event_channels.insert(child, channel); + app.agent_navigation + .upsert(child, nick, role, /*is_closed*/ false); + let store = app.thread_event_channels[&child].store.clone(); + let config_path = codex_home.path().join("config.toml"); + let original_user_config = fs::read_to_string(&config_path).ok(); + let (local, url) = (app.environment_manager.clone(), Some("ws://[::1]".into())); + let remote = Arc::new(EnvironmentManager::create_for_tests(url, runtime).await); + let mut history = || { + std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell), + _ => None, + }) + .map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 200))) + .collect::>() + }; + let change = |thread_id, path: &str| AppEvent::ChangeWorkingDirectory { + thread_id, + requested_cwd: path.into(), + }; + for (path, kind, expected) in [ + ("missing", "local", "Cannot access directory"), + ("../config.toml", "local", "Not a directory"), + (r"C:\bad", "workspace", "not supported for remote"), + ("~", "executor", "not supported for remote"), + ("../trusted", "stale", "requires an idle primary session"), + ("../trusted", "running", "another agent is running"), + ("../trusted", "active", "another agent is running"), + ("../trusted", "mcp", "inventory is still loading"), + ("../trusted", "approval", "approval policy override"), + ("../trusted", "profile", "permission profile override"), + ("../trusted", "reviewer", "reviewer"), + ("../p", "named", "different settings"), + ("../p", "keymap", "open_transcript"), + ("../unknown", "local", "This directory is not trusted"), + ("../trusted", "main", "background terminals"), + ("../trusted", "child", "background terminals"), + ] { + app.config.approvals_reviewer = ApprovalsReviewer::User; + if kind == "reviewer" { + app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; + fs::write(&requirements, "allowed_approvals_reviewers = [\"user\"]")?; + } + app.agent_navigation.set_running(child, kind == "running"); + store.lock().await.active_turn_id = (kind == "active").then(|| "active".into()); + app.loader_overrides.system_requirements_path = + matches!(kind, "approval" | "profile" | "reviewer").then_some(requirements.clone()); + app.harness_overrides.permission_profile = + (kind != "named").then_some(PermissionProfile::workspace_write()); + app.runtime_approval_policy_override = + (kind == "approval").then_some(AskForApproval::OnRequest); + let mut profile = RuntimePermissionProfileOverride::from_config(&app.config); + profile.active_permission_profile = + (kind == "named").then(|| ActivePermissionProfile::new("dev")); + app.runtime_permission_profile_override = + matches!(kind, "profile" | "reviewer" | "named").then_some(profile); + app.app_server_target = crate::AppServerTarget::Embedded; + if kind == "workspace" { + let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:8765")?; + app.app_server_target = crate::AppServerTarget::Remote { endpoint }; + } + app.environment_manager = [&local, &remote][usize::from(kind == "executor")].clone(); + requests.lock().expect("request recorder lock").clear(); + if kind == "mcp" { + let loading = history_cell::new_mcp_inventory_loading; + app.transcript_cells + .push(Arc::new(loading(/*animations_enabled*/ false))); + } else if kind == "child" { + app.thread_event_channels.remove(&child); + } + let thread_id = [original, ThreadId::new()][usize::from(kind == "stale")]; + app.handle_event(&mut tui, &mut server, change(thread_id, path)) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(original)); + assert_eq!(app.config.cwd, current.clone().abs()); + assert!(app.runtime_working_directory_override.is_none()); + let count = requests.lock().expect("request recorder lock").len(); + let checked = usize::from(kind == "main") + 2 * usize::from(kind == "child"); + assert_eq!(count, checked, "{kind}"); + let listed = recorded_params(&requests, "thread/backgroundTerminals/list"); + let mut ids = listed.iter().zip([original, child]); + assert!(ids.all(|(p, id)| p["threadId"] == id.to_string())); + assert_eq!(fs::read_to_string(&config_path).ok(), original_user_config); + let output = history().join(""); + if kind == "mcp" { + assert_snapshot!(output, @"■ MCP inventory is still loading."); + } + assert!(output.contains(expected), "{path}"); + app.clear_committed_mcp_inventory_loading(); + } + app.set_approvals_reviewer_in_app_and_widget(ApprovalsReviewer::AutoReview); + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + for (path, expected) in [(&failed, 0), (&trusted, 2)] { + requests.lock().expect("request recorder lock").clear(); + app.change_working_directory(&mut tui, &mut server, path.clone().abs()) + .await; + assert_eq!(app.chat_widget.thread_id(), Some(original)); + assert_eq!(app.config.cwd, current.clone().abs()); + assert_eq!(rec(&requests, "thread/unsubscribe").len(), expected); + assert!(history().join("").contains("change")); + } + let removed = recorded_params(&requests, "thread/unsubscribe"); + let archived = recorded_params(&requests, "thread/archive"); + assert_eq!(removed[0]["threadId"], original.to_string()); + assert_eq!(archived[0]["threadId"], removed[1]["threadId"]); + requests.lock().expect("request recorder lock").clear(); + app.handle_event(&mut tui, &mut server, change(original, "../trusted")) + .await?; + let forked = app.chat_widget.thread_id().expect("forked thread"); + assert_ne!(forked, original); + let forked_rollout = app.chat_widget.rollout_path().expect("forked rollout"); + assert!(fs::read_to_string(&rollout)?.contains("saved history")); + let copied = fs::read_to_string(&forked_rollout)?; + let meta = codex_rollout::read_session_meta_line(&forked_rollout).await?; + let base = meta.meta.history_base; + assert!(copied.contains("saved history") || base.is_some_and(|h| h.thread_id == original)); + assert_eq!(app.config.cwd, trusted.clone().abs()); + let configured = app.primary_session_configured.as_ref().expect("session"); + let source = codex_utils_path_uri::PathUri::from_abs_path(&agents.abs()); + assert!(configured.instruction_source_paths.contains(&source)); + let (cwd, result) = (current.clone(), Err("stale skills".into())); + let skills = AppEvent::SkillsListLoaded { cwd, result }; + let (cwd, plugins) = (current.clone(), Some(vec![])); + let plugins = AppEvent::PluginMentionsLoaded { cwd, plugins }; + let diff = AppEvent::DiffResult(current.clone(), "stale diff".to_string()); + let branch = AppEvent::SyncThreadGitBranch { + thread_id: original, + branch: "stale".to_string(), + cwd: current.clone(), + }; + for event in [diff, skills, plugins, branch] { + app.handle_event(&mut tui, &mut server, event).await?; + } + let path = trusted.to_str().expect("trusted path"); + let output = history().join("").replace(path, ""); + let message = &output[output.rfind('•').expect("change")..]; + assert_snapshot!(message, @"• Working directory changed to: "); + assert!(!output.contains("stale skills")); + assert!(app.overlay.is_none()); + assert_eq!(app.keymap.app.open_transcript, vec![plain(KeyCode::F(12))]); + let anchor = app.runtime_working_directory_override.as_deref(); + assert_eq!(anchor, Some(trusted.as_path())); + let effort = app.chat_widget.current_reasoning_effort(); + assert_eq!(effort, Some(ReasoningEffortConfig::High)); + let approval = app.config.permissions.approval_policy.value(); + assert_eq!(approval, AskForApproval::OnRequest.to_core()); + let forks = recorded_params(&requests, "thread/fork"); + assert_eq!(forks.len(), 1); + let params = &forks[0]; + assert_eq!(params["threadId"], serde_json::json!(original.to_string())); + assert_eq!(params["cwd"], serde_json::json!(trusted)); + assert_eq!(params["approvalsReviewer"].as_str(), Some("auto_review")); + assert_eq!(params["developerInstructions"], "destination policy"); + assert_eq!(params["deferGoalContinuation"], serde_json::json!(true)); + assert_eq!(¶ms["runtimeWorkspaceRoots"], &json!([trusted])); + assert_eq!(rec(&requests, "hooks/list")[0]["cwds"], json!([trusted])); + for suffix in "start resume settings/update archive".split(' ') { + assert!(recorded_params(&requests, &format!("thread/{suffix}")).is_empty()); + } + assert!(rec(req, "thread/metadata/update")[0]["threadId"] == params["threadId"]); + let removed = recorded_params(&requests, "thread/unsubscribe"); + assert_eq!(removed.len(), 2); + let found = |id: ThreadId| removed.iter().any(|p| p["threadId"] == id.to_string()); + assert!([original, child].into_iter().all(found)); + let retained = server.thread_read(original, /*include_turns*/ false); + assert_eq!(retained.await?.cwd, current.abs().canonicalize()?); + assert_eq!(app.chat_widget.config_ref().cwd, trusted.clone().abs()); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("SessionStart")); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.harness_overrides.bypass_hook_trust = Some(true); + requests.lock().expect("request recorder lock").clear(); + app.change_working_directory(&mut tui, &mut server, trusted.abs()) + .await; + assert!(app.config.bypass_hook_trust && !app.chat_widget.has_active_view()); + assert!(recorded_params(&requests, "hooks/list").is_empty()); + app.harness_overrides.bypass_hook_trust = None; + requests.lock().expect("request recorder lock").clear(); + app.change_working_directory(&mut tui, &mut server, untrusted.clone().abs()) + .await; + assert_eq!(app.config.active_project.trust_level, Some(T::Untrusted)); + let approval = app.config.permissions.approval_policy.value(); + assert_eq!(approval, AskForApproval::UnlessTrusted.to_core()); + assert_eq!(rec(req, "thread/fork")[0]["approvalPolicy"], "untrusted"); + let warning = "Project-local config, hooks, and exec policies are disabled"; + assert!(history().iter().any(|line| line.contains(warning))); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + #[test] fn fresh_session_applies_requested_name() -> Result<()> { const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs new file mode 100644 index 0000000000..7f4dad99b8 --- /dev/null +++ b/codex-rs/tui/src/app/working_directory.rs @@ -0,0 +1,196 @@ +//! Local working-directory changes that preserve conversation history. + +use super::session_lifecycle::ThreadAttachPresentation; +use super::*; +use crate::app_server_session::ForkGoalContinuation::DeferUntilNextTurn; +use crate::history_cell::McpInventoryLoadingCell as LoadingCell; +use codex_app_server_protocol::ThreadBackgroundTerminalsListParams; +use codex_app_server_protocol::ThreadBackgroundTerminalsListResponse as ListResponse; + +impl App { + fn working_directory_error(&mut self, message: impl Into) { + self.chat_widget.add_error_message(message.into()); + } + + pub(super) async fn change_working_directory( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + cwd: AbsolutePathBuf, + ) { + if self.config.ephemeral || !cwd.as_path().is_dir() { + return self.working_directory_error("This task cannot be safely replaced."); + } + let Some(thread_id) = self.chat_widget.thread_id() else { + return; + }; + let cells = &self.transcript_cells; + if cells.iter().any(|cell| cell.as_any().is::()) { + return self.working_directory_error("MCP inventory is still loading."); + } + let agents = self.agent_navigation.ordered_threads(); + let active = self.thread_event_channels.iter().any(|(id, channel)| { + let store = channel.store.try_lock(); + *id != thread_id && !store.is_ok_and(|store| store.active_turn_id().is_none()) + }); + if active || agents.iter().any(|(t, a)| *t != thread_id && a.is_running) { + return self.working_directory_error("Cannot change: another agent is running."); + } + let mut config = match self.rebuild_config_for_cwd(cwd.to_path_buf()).await { + Ok(config) => config, + Err(err) => return self.working_directory_error(format!("Cannot load {cwd:?}: {err}")), + }; + if config.active_project.trust_level.is_none() { + return self.working_directory_error("This directory is not trusted; run Codex there."); + } + if let Some(profile) = self.runtime_permission_profile_override.as_ref() + && profile.active_permission_profile.is_some() + && (RuntimePermissionProfileOverride::from_config(&config) != *profile + || config.permissions.profile_workspace_roots() + != self.config.permissions.profile_workspace_roots()) + { + return self.working_directory_error("Permission profile has different settings."); + } + self.apply_runtime_policy_overrides(&mut config); + if self.runtime_permission_profile_override.is_some() { + let reviewer = self.config.approvals_reviewer; + let reviewers = &config.config_layer_stack.requirements().approvals_reviewer; + if let Err(error) = reviewers.can_set(&reviewer) { + return self.working_directory_error(format!("Approvals reviewer: {error}")); + } + config.approvals_reviewer = reviewer; + } + let actual = config.permissions.approval_policy.value(); + let snapshot = RuntimePermissionProfileOverride::from_config(&config); + let approval = self.runtime_approval_policy_override; + let profile = self.runtime_permission_profile_override.as_ref(); + if approval.is_some_and(|p| actual != p.to_core()) + || profile.is_some_and(|p| snapshot != *p) + { + return; + } + let keymap = match RuntimeKeymap::from_config(&config.tui_keymap) { + Ok(keymap) => keymap, + Err(error) => return self.chat_widget.add_error_message(error), + }; + config.service_tier = self.chat_widget.configured_service_tier(); + let rollout = self.chat_widget.rollout_path(); + let has_rollout = rollout.as_deref().is_some_and(rollout_path_is_resumable); + let channels = &self.thread_event_channels; + if !has_rollout + && (!self.chat_widget.token_usage().is_zero() + || channels.get(&thread_id).is_some_and(|channel| { + channel.store.try_lock().map_or(/*default*/ true, |store| { + !store.turns.is_empty() + || store.buffer.iter().any(|event| { + matches!( + event, + ThreadBufferedEvent::Notification(notification) + if matches!(notification.as_ref(), + ServerNotification::TurnStarted(_) + | ServerNotification::TurnCompleted(_)) + ) + }) + }) + })) + { + return self.working_directory_error("Conversation history is not saved."); + } + let mut ids: HashSet<_> = channels.keys().copied().collect(); + for (id, agent) in self.agent_navigation.ordered_threads() { + ids.extend((!agent.is_closed).then_some(id)); + } + let descendants = ids.iter().copied().filter(|id| *id != thread_id); + for tracked_id in std::iter::once(thread_id).chain(descendants) { + let request = ClientRequest::ThreadBackgroundTerminalsList { + request_id: app_server.next_request_id(), + params: ThreadBackgroundTerminalsListParams { + thread_id: tracked_id.to_string(), + cursor: None, + limit: Some(1), + }, + }; + let handle = app_server.request_handle(); + let result = handle.request_typed::(request).await; + if !matches!(result, Ok(response) if response.data.is_empty()) { + return self.working_directory_error("Active background terminals block /cd."); + } + } + let transitioned = if has_rollout { + app_server + .fork_thread_at( + config.clone(), + thread_id, + /*last_turn_id*/ None, + /*before_turn_id*/ None, + DeferUntilNextTurn, + ) + .await + } else { + app_server + .start_thread_with_session_start_source(&config, /*session_start_source*/ None) + .await + }; + let transitioned = match transitioned { + Ok(value) => value, + Err(e) => return self.working_directory_error(format!("Failed to change: {e}")), + }; + let session = &transitioned.session; + if session.thread_id == thread_id + || crate::session_resume::cwds_differ(session.cwd.as_path(), cwd.as_path()) + || session.runtime_workspace_roots != config.workspace_roots + || session.approval_policy.to_core() != config.permissions.approval_policy.value() + || session.approvals_reviewer != config.approvals_reviewer + || session.active_permission_profile != config.permissions.active_permission_profile() + { + if session.thread_id != thread_id { + let _ = app_server.thread_unsubscribe(session.thread_id).await; + if has_rollout { + let _ = app_server.thread_archive(session.thread_id).await; + } + } + return self.working_directory_error("Requested directory or permissions not applied."); + } + if let Err(error) = app_server.thread_unsubscribe(thread_id).await { + let replacement_id = transitioned.session.thread_id; + let _ = app_server.thread_unsubscribe(replacement_id).await; + if has_rollout { + let _ = app_server.thread_archive(replacement_id).await; + } + return self.working_directory_error(format!("Cannot change directories: {error}")); + } + for tracked_id in ids.into_iter().filter(|id| *id != thread_id) { + if let Err(error) = app_server.thread_unsubscribe(tracked_id).await { + tracing::warn!("failed to unsubscribe tracked thread {tracked_id}: {error}"); + } + } + self.config = config; + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + let notify = &self.config.tui_notifications; + tui.set_notification_settings(notify.method, notify.condition); + if let Err(error) = tui.clear_ambient_pet_image() { + tracing::warn!(%error, "failed to clear ambient pet image"); + } + let attach = App::replace_chat_widget_with_app_server_thread; + let (lineage, message) = (ThreadAttachPresentation::SessionLineage, None); + if let Err(error) = attach(self, tui, transitioned, lineage, message).await { + return self.working_directory_error(format!("Could not restore session: {error}")); + } + self.cancel_pending_key_chord(); + self.keymap = keymap; + self.restore_runtime_theme_from_config(); + self.runtime_working_directory_override = Some(cwd.to_path_buf()); + emit_project_config_warnings(&self.app_event_tx, &self.config); + let message = format!("Working directory changed to: {}", cwd.display()); + self.chat_widget.add_info_message(message, /*hint*/ None); + if !self.config.bypass_hook_trust { + let load_review = crate::startup_hooks_review::load_startup_hooks_review_entry; + let hooks = load_review(app_server.request_handle(), cwd.to_path_buf()).await; + if hooks.hooks.iter().any(crate::hooks_rpc::hook_needs_review) { + self.chat_widget.open_hooks_browser(hooks); + } + } + tui.frame_requester().schedule_frame(); + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 6fde0759d6..8b7f6fe901 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -263,6 +263,7 @@ pub(crate) enum AppEvent { SyncThreadGitBranch { thread_id: ThreadId, branch: String, + cwd: PathBuf, }, /// Fetch a persistent cross-session message history entry by offset. @@ -284,6 +285,12 @@ pub(crate) enum AppEvent { name: Option, }, + /// Change the working directory of the originating idle primary thread. + ChangeWorkingDirectory { + thread_id: ThreadId, + requested_cwd: PathBuf, + }, + /// Result of the fresh startup thread that is attached after the input UI is live. StartupThreadStarted { result: Result, @@ -504,7 +511,7 @@ pub(crate) enum AppEvent { }, /// Result of computing a `/diff` command. - DiffResult(String), + DiffResult(PathBuf, String), /// Open the app link view in the bottom pane. OpenAppLink { @@ -748,6 +755,7 @@ pub(crate) enum AppEvent { /// Result of refreshing plugin mention bindings. PluginMentionsLoaded { + cwd: PathBuf, plugins: Option>, }, @@ -778,6 +786,7 @@ pub(crate) enum AppEvent { /// command path because those callers expect the visible skill state to be current when their command /// completes. SkillsListLoaded { + cwd: PathBuf, result: Result, }, diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 139990e40f..9d165445f1 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -309,6 +309,7 @@ mod tests { SlashCommand::Diff, SlashCommand::Mention, SlashCommand::Status, + SlashCommand::Pwd, SlashCommand::Usage, ] ); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap index f7c45aec63..3e1d02c211 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap @@ -31,6 +31,8 @@ expression: commands /diff - show git diff (including untracked files) /mention - mention a file /status - show current session configuration and token usage +/cd - change the current working directory +/pwd - show the current working directory /title - configure which items appear in the terminal title /statusline - configure which items appear in the status line /theme - choose a syntax highlighting theme diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2e31278911..754c651d82 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -438,6 +438,7 @@ mod turn_runtime; use self::turn_lifecycle::TurnLifecycleState; mod usage; mod user_messages; +mod working_directory; use self::user_messages::PendingSteer; use self::user_messages::PendingSteerCompareKey; use self::user_messages::QueueDrain; diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index fafe2e079b..da5943b5d5 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -138,7 +138,7 @@ impl ChatWidget { || (self.bottom_pane.is_task_running() && (self.mcp_startup_status.is_none() || self.input_queue.user_turn_pending_start)))) - || (cmd == SlashCommand::Resume + || (matches!(cmd, SlashCommand::Resume | SlashCommand::Cd) && (self.input_queue.user_turn_pending_start || self.turn_lifecycle.agent_turn_running)) || (cmd == SlashCommand::Export && self.input_queue.suppress_queue_autosend) @@ -430,7 +430,7 @@ impl ChatWidget { None => "Failed to compute diff: workspace command runner unavailable" .to_string(), }; - tx.send(AppEvent::DiffResult(text)); + tx.send(AppEvent::DiffResult(cwd, text)); }); } SlashCommand::Mention => { @@ -461,6 +461,15 @@ impl ChatWidget { ); } } + SlashCommand::Cd => { + self.dispatch_command_with_args(SlashCommand::Cd, "~".to_string(), Vec::new()); + } + SlashCommand::Pwd => { + self.add_info_message( + format!("Current working directory: {}", self.config.cwd.display()), + /*hint*/ None, + ); + } SlashCommand::Usage => { if self.ensure_usage_command_available() { self.open_usage_menu(); @@ -694,6 +703,10 @@ impl ChatWidget { ), }); } + SlashCommand::Cd => self.request_working_directory_change(trimmed), + SlashCommand::Pwd => { + self.add_error_message("Usage: /pwd".to_string()); + } SlashCommand::Usage => { if self.ensure_usage_command_available() { match tokens::TokenActivityView::parse(trimmed) { @@ -1085,6 +1098,7 @@ impl ChatWidget { match cmd { SlashCommand::Ide | SlashCommand::Status + | SlashCommand::Pwd | SlashCommand::Usage | SlashCommand::DebugConfig | SlashCommand::Ps @@ -1102,6 +1116,10 @@ impl ChatWidget { | SlashCommand::App | SlashCommand::Rename | SlashCommand::TestApproval => QueueDrain::Continue, + SlashCommand::Cd => match self.thread_id { + Some(thread_id) if self.can_change_working_directory(thread_id) => QueueDrain::Stop, + _ => QueueDrain::Continue, + }, SlashCommand::Feedback | SlashCommand::Export | SlashCommand::New diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index dcccbd027f..9c19a93d22 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -338,13 +338,18 @@ impl ChatWidget { && let Some(thread_id) = self.thread_id && let Some(runner) = self.workspace_command_runner.clone() { - let cwd = PathBuf::from(cwd); + let branch_cwd = PathBuf::from(cwd); + let cwd = self.config.cwd.to_path_buf(); let tx = self.app_event_tx.clone(); tokio::spawn(async move { if let Some(branch) = - crate::branch_summary::current_branch_name(runner.as_ref(), &cwd).await + crate::branch_summary::current_branch_name(runner.as_ref(), &branch_cwd).await { - tx.send(AppEvent::SyncThreadGitBranch { thread_id, branch }); + tx.send(AppEvent::SyncThreadGitBranch { + thread_id, + branch, + cwd, + }); } }); } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index bb62505808..2997c0a24e 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2635,6 +2635,109 @@ async fn slash_app_without_thread_id_shows_starting_error() { ); } +#[tokio::test] +async fn slash_pwd_and_cwd_alias_display_current_working_directory_from_composer() { + let mut output = Vec::new(); + for (command, side) in [("/pwd", false), ("/cwd", true), ("/pwd x", false)] { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_side_conversation_active(side); + chat.bottom_pane.set_task_running(!side); + chat.bottom_pane + .set_composer_text(command.to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + let [cell]: [_; 1] = drain_insert_history(&mut rx).try_into().expect("one cell"); + output.push(normalize_snapshot_paths(lines_to_single_string(&cell))); + } + insta::assert_snapshot!(output.join(""), @r" +• Current working directory: /tmp/project +• Current working directory: /tmp/project +■ Usage: /pwd +"); +} + +#[tokio::test] +async fn slash_cd_changes_current_session_after_replay_and_defaults_to_home() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + chat.thread_name = Some("Completed task".to_string()); + chat.forked_from = Some(ThreadId::new()); + let (status, duration, error) = (AppServerTurnStatus::Completed, None, None); + let turn = app_server_turn("turn-1", status, duration, error); + chat.replay_thread_turns(vec![turn], ReplayKind::ResumeInitialMessages); + drain_insert_history(&mut rx); + assert!(!chat.can_change_working_directory(ThreadId::new())); + let drain = chat.submit_queued_slash_prompt(UserMessage::from("/cd /tmp").into()); + assert_eq!(drain, QueueDrain::Stop); + chat.bottom_pane + .set_composer_text("/cd".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + for expected in ["/tmp", "~"] { + assert_matches!( + rx.try_recv(), + Ok(AppEvent::ChangeWorkingDirectory { thread_id: actual, requested_cwd }) + if actual == thread_id && requested_cwd == std::path::Path::new(expected) + ); + } + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[tokio::test] +async fn rejected_queued_cd_drains_following_input() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + handle_turn_started(&mut chat, "turn-1"); + queue_composer_text_with_tab(&mut chat, "/cd /tmp"); + queue_composer_text_with_tab(&mut chat, "follow-up"); + complete_turn_with_message(&mut chat, "turn-1", /*message*/ None); + assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. }); + assert!(chat.input_queue.queued_user_messages.is_empty()); + while let Ok(event) = rx.try_recv() { + assert!(!matches!(event, AppEvent::ChangeWorkingDirectory { .. })); + } +} + +#[tokio::test] +async fn slash_cd_rejects_pending_input_and_unsupported_session_ownership() { + let mut errors = Vec::new(); + for state in "new active pending queued steer side owned ephemeral mcp exec".split(' ') { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = (state != "new").then(ThreadId::new); + let (queued, steer) = (UserMessage::from("q").into(), pending_steer("s")); + match state { + "active" => chat.bottom_pane.set_task_running(/*running*/ true), + "pending" => chat.input_queue.user_turn_pending_start = true, + "queued" => chat.input_queue.queued_user_messages.push_back(queued), + "steer" => chat.input_queue.pending_steers.push_back(steer), + "side" => chat.set_side_conversation_active(/*active*/ true), + "owned" => chat.set_parent_owned_thread(), + "ephemeral" => chat.config.ephemeral = true, + "exec" => chat.track_unified_exec_process_begin("call", Some("process"), "sleep"), + "mcp" => { + let cell = + history_cell::new_mcp_inventory_loading(/*animations_enabled*/ false); + chat.transcript.active_cell = Some(Box::new(cell)); + } + _ => {} + } + + chat.dispatch_command_with_args(SlashCommand::Cd, "/tmp".to_string(), Vec::new()); + + assert!(state != "mcp" || chat.transcript.active_cell.is_some()); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + let Ok(AppEvent::InsertHistoryCell(cell)) = rx.try_recv() else { + unreachable!("missing error for {state}"); + }; + errors.push(lines_to_single_string(&cell.display_lines(/*width*/ 200))); + assert!(rx.try_recv().is_err(), "{state}"); + } + insta::assert_snapshot!(format!("{}{}", errors[0], errors[3]), @r" +■ The session must start before you can change its working directory. +■ Changing directories requires an idle primary session without queued input. +"); +} + #[tokio::test] async fn slash_rollout_displays_current_path() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/working_directory.rs b/codex-rs/tui/src/chatwidget/working_directory.rs new file mode 100644 index 0000000000..27e5322184 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/working_directory.rs @@ -0,0 +1,47 @@ +//! Working-directory slash commands for the current local session. + +use super::*; +use crate::history_cell::McpInventoryLoadingCell; + +impl ChatWidget { + pub(crate) fn can_change_working_directory(&self, thread_id: ThreadId) -> bool { + let active = &self.transcript.active_cell; + self.thread_id == Some(thread_id) + && !self.active_side_conversation + && !self.blocks_direct_input + && !self.config.ephemeral + && self.unified_exec_processes.is_empty() + && !self.is_user_turn_pending_or_running() + && !self.input_queue.has_queued_follow_up_messages() + && self.input_queue.pending_steers.is_empty() + && active + .as_ref() + .is_none_or(|cell| !cell.as_any().is::()) + } + + pub(super) fn request_working_directory_change(&mut self, path: &str) { + if !self.is_session_configured() { + self.add_error_message( + "The session must start before you can change its working directory.".to_string(), + ); + return; + } + + let Some(thread_id) = self.thread_id else { + return; + }; + if !self.can_change_working_directory(thread_id) { + let message = + "Changing directories requires an idle primary session without queued input."; + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(message.to_string()), + ))); + return; + } + + self.app_event_tx.send(AppEvent::ChangeWorkingDirectory { + thread_id, + requested_cwd: PathBuf::from(path), + }); + } +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index b369870346..5a00072f15 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -49,6 +49,9 @@ pub enum SlashCommand { Diff, Mention, Status, + Cd, + #[strum(to_string = "pwd", serialize = "cwd")] + Pwd, Usage, DebugConfig, Title, @@ -105,6 +108,8 @@ impl SlashCommand { SlashCommand::Import => "import setup, this project, and recent chats from Claude Code", SlashCommand::Hooks => "view and manage lifecycle hooks", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Cd => "change the current working directory", + SlashCommand::Pwd => "show the current working directory", SlashCommand::Usage => "view account usage or use a usage limit reset", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", @@ -167,6 +172,8 @@ impl SlashCommand { | SlashCommand::Mcp | SlashCommand::Export | SlashCommand::Raw + | SlashCommand::Cd + | SlashCommand::Pwd | SlashCommand::Usage | SlashCommand::Pets | SlashCommand::Side @@ -186,6 +193,7 @@ impl SlashCommand { | SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status + | SlashCommand::Pwd | SlashCommand::Usage | SlashCommand::Ide ) @@ -210,6 +218,7 @@ impl SlashCommand { | SlashCommand::Import | SlashCommand::Review | SlashCommand::Plan + | SlashCommand::Cd | SlashCommand::Clear | SlashCommand::Logout | SlashCommand::MemoryDrop @@ -226,6 +235,7 @@ impl SlashCommand { | SlashCommand::Skills | SlashCommand::Hooks | SlashCommand::Status + | SlashCommand::Pwd | SlashCommand::Usage | SlashCommand::DebugConfig | SlashCommand::Ps