Add working-directory commands to the TUI (#38894)

## What changed

- Add `/cd [path]` for changing an idle local session's working directory while preserving conversation history. Relative paths resolve from the current directory, and omitting the path selects `~`.
- Reload project configuration, instructions, permissions, keybindings, file search, and hooks for the destination before replacing the active session.
- Reject unsafe transitions, including active or queued work, background terminals, remote environments, untrusted destinations, and incompatible permission profiles.
- Add `/pwd` and its `/cwd` alias to display the current working directory, and discard asynchronous results left over from the previous directory.

## Testing

- Cover command parsing, aliases, default paths, session eligibility, transition failures, history preservation, destination policy loading, hooks, and stale events.

GitOrigin-RevId: 1a987c029f157a087ba02c0f26e022260409132b
This commit is contained in:
Benjamin Carlsson
2026-08-16 19:48:07 +00:00
committed by copyberry
parent 375996d3f5
commit ed32158e90
18 changed files with 776 additions and 24 deletions
+3
View File
@@ -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<PathBuf>,
pub(crate) state_db: Option<StateDbHandle>,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
+5 -4
View File
@@ -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),
});
}
+53 -4
View File
@@ -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<AppRunControl> {
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;
}
+2 -2
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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(),
+46 -6
View File
@@ -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(
@@ -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::<ClientRequest>(
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(&current)?;
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::<Vec<_>>()
};
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, "<PROJECT>");
let message = &output[output.rfind('•').expect("change")..];
assert_snapshot!(message, @"• Working directory changed to: <PROJECT>");
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!(&params["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;
+196
View File
@@ -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<String>) {
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::<LoadingCell>()) {
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::<ListResponse>(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();
}
}
+10 -1
View File
@@ -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<String>,
},
/// 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<AppServerStartedThread, String>,
@@ -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<Vec<PluginCapabilitySummary>>,
},
@@ -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<SkillsListResponse, String>,
},
@@ -309,6 +309,7 @@ mod tests {
SlashCommand::Diff,
SlashCommand::Mention,
SlashCommand::Status,
SlashCommand::Pwd,
SlashCommand::Usage,
]
);
@@ -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
+1
View File
@@ -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;
+20 -2
View File
@@ -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
+8 -3
View File
@@ -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,
});
}
});
}
@@ -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;
@@ -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::<McpInventoryLoadingCell>())
}
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),
});
}
}
+10
View File
@@ -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