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