mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
committed by
copyberry
parent
375996d3f5
commit
ed32158e90
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(¤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::<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!(¶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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user