mirror of
https://github.com/openai/codex.git
synced 2026-11-03 16:23:51 +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:
1 parent
375996d3f5
commit
ed32158e90
18 files changed
+776
-24
No files matched your search
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user