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

## What changed

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

## Testing

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

GitOrigin-RevId: 1a987c029f157a087ba02c0f26e022260409132b
This commit is contained in:
Benjamin Carlsson authored and copyberry committed 2026-08-16 19:48:07 +00:00
1 parent 375996d3f5
commit ed32158e90
18 files changed
+776 -24

No files matched your search

+20 -2
View File
@@ -138,7 +138,7 @@ impl ChatWidget {
|| (self.bottom_pane.is_task_running()
&& (self.mcp_startup_status.is_none()
|| self.input_queue.user_turn_pending_start))))
|| (cmd == SlashCommand::Resume
|| (matches!(cmd, SlashCommand::Resume | SlashCommand::Cd)
&& (self.input_queue.user_turn_pending_start
|| self.turn_lifecycle.agent_turn_running))
|| (cmd == SlashCommand::Export && self.input_queue.suppress_queue_autosend)
@@ -430,7 +430,7 @@ impl ChatWidget {
None => "Failed to compute diff: workspace command runner unavailable"
.to_string(),
};
tx.send(AppEvent::DiffResult(text));
tx.send(AppEvent::DiffResult(cwd, text));
});
}
SlashCommand::Mention => {
@@ -461,6 +461,15 @@ impl ChatWidget {
);
}
}
SlashCommand::Cd => {
self.dispatch_command_with_args(SlashCommand::Cd, "~".to_string(), Vec::new());
}
SlashCommand::Pwd => {
self.add_info_message(
format!("Current working directory: {}", self.config.cwd.display()),
/*hint*/ None,
);
}
SlashCommand::Usage => {
if self.ensure_usage_command_available() {
self.open_usage_menu();
@@ -694,6 +703,10 @@ impl ChatWidget {
),
});
}
SlashCommand::Cd => self.request_working_directory_change(trimmed),
SlashCommand::Pwd => {
self.add_error_message("Usage: /pwd".to_string());
}
SlashCommand::Usage => {
if self.ensure_usage_command_available() {
match tokens::TokenActivityView::parse(trimmed) {
@@ -1085,6 +1098,7 @@ impl ChatWidget {
match cmd {
SlashCommand::Ide
| SlashCommand::Status
| SlashCommand::Pwd
| SlashCommand::Usage
| SlashCommand::DebugConfig
| SlashCommand::Ps
@@ -1102,6 +1116,10 @@ impl ChatWidget {
| SlashCommand::App
| SlashCommand::Rename
| SlashCommand::TestApproval => QueueDrain::Continue,
SlashCommand::Cd => match self.thread_id {
Some(thread_id) if self.can_change_working_directory(thread_id) => QueueDrain::Stop,
_ => QueueDrain::Continue,
},
SlashCommand::Feedback
| SlashCommand::Export
| SlashCommand::New
+8 -3
View File
@@ -338,13 +338,18 @@ impl ChatWidget {
&& let Some(thread_id) = self.thread_id
&& let Some(runner) = self.workspace_command_runner.clone()
{
let cwd = PathBuf::from(cwd);
let branch_cwd = PathBuf::from(cwd);
let cwd = self.config.cwd.to_path_buf();
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
if let Some(branch) =
crate::branch_summary::current_branch_name(runner.as_ref(), &cwd).await
crate::branch_summary::current_branch_name(runner.as_ref(), &branch_cwd).await
{
tx.send(AppEvent::SyncThreadGitBranch { thread_id, branch });
tx.send(AppEvent::SyncThreadGitBranch {
thread_id,
branch,
cwd,
});
}
});
}
@@ -2635,6 +2635,109 @@ async fn slash_app_without_thread_id_shows_starting_error() {
);
}
#[tokio::test]
async fn slash_pwd_and_cwd_alias_display_current_working_directory_from_composer() {
let mut output = Vec::new();
for (command, side) in [("/pwd", false), ("/cwd", true), ("/pwd x", false)] {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_side_conversation_active(side);
chat.bottom_pane.set_task_running(!side);
chat.bottom_pane
.set_composer_text(command.to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
let [cell]: [_; 1] = drain_insert_history(&mut rx).try_into().expect("one cell");
output.push(normalize_snapshot_paths(lines_to_single_string(&cell)));
}
insta::assert_snapshot!(output.join(""), @r"
• Current working directory: /tmp/project
• Current working directory: /tmp/project
■ Usage: /pwd
");
}
#[tokio::test]
async fn slash_cd_changes_current_session_after_replay_and_defaults_to_home() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.thread_name = Some("Completed task".to_string());
chat.forked_from = Some(ThreadId::new());
let (status, duration, error) = (AppServerTurnStatus::Completed, None, None);
let turn = app_server_turn("turn-1", status, duration, error);
chat.replay_thread_turns(vec![turn], ReplayKind::ResumeInitialMessages);
drain_insert_history(&mut rx);
assert!(!chat.can_change_working_directory(ThreadId::new()));
let drain = chat.submit_queued_slash_prompt(UserMessage::from("/cd /tmp").into());
assert_eq!(drain, QueueDrain::Stop);
chat.bottom_pane
.set_composer_text("/cd".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
for expected in ["/tmp", "~"] {
assert_matches!(
rx.try_recv(),
Ok(AppEvent::ChangeWorkingDirectory { thread_id: actual, requested_cwd })
if actual == thread_id && requested_cwd == std::path::Path::new(expected)
);
}
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn rejected_queued_cd_drains_following_input() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/cd /tmp");
queue_composer_text_with_tab(&mut chat, "follow-up");
complete_turn_with_message(&mut chat, "turn-1", /*message*/ None);
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });
assert!(chat.input_queue.queued_user_messages.is_empty());
while let Ok(event) = rx.try_recv() {
assert!(!matches!(event, AppEvent::ChangeWorkingDirectory { .. }));
}
}
#[tokio::test]
async fn slash_cd_rejects_pending_input_and_unsupported_session_ownership() {
let mut errors = Vec::new();
for state in "new active pending queued steer side owned ephemeral mcp exec".split(' ') {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = (state != "new").then(ThreadId::new);
let (queued, steer) = (UserMessage::from("q").into(), pending_steer("s"));
match state {
"active" => chat.bottom_pane.set_task_running(/*running*/ true),
"pending" => chat.input_queue.user_turn_pending_start = true,
"queued" => chat.input_queue.queued_user_messages.push_back(queued),
"steer" => chat.input_queue.pending_steers.push_back(steer),
"side" => chat.set_side_conversation_active(/*active*/ true),
"owned" => chat.set_parent_owned_thread(),
"ephemeral" => chat.config.ephemeral = true,
"exec" => chat.track_unified_exec_process_begin("call", Some("process"), "sleep"),
"mcp" => {
let cell =
history_cell::new_mcp_inventory_loading(/*animations_enabled*/ false);
chat.transcript.active_cell = Some(Box::new(cell));
}
_ => {}
}
chat.dispatch_command_with_args(SlashCommand::Cd, "/tmp".to_string(), Vec::new());
assert!(state != "mcp" || chat.transcript.active_cell.is_some());
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
let Ok(AppEvent::InsertHistoryCell(cell)) = rx.try_recv() else {
unreachable!("missing error for {state}");
};
errors.push(lines_to_single_string(&cell.display_lines(/*width*/ 200)));
assert!(rx.try_recv().is_err(), "{state}");
}
insta::assert_snapshot!(format!("{}{}", errors[0], errors[3]), @r"
■ The session must start before you can change its working directory.
■ Changing directories requires an idle primary session without queued input.
");
}
#[tokio::test]
async fn slash_rollout_displays_current_path() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -0,0 +1,47 @@
//! Working-directory slash commands for the current local session.
use super::*;
use crate::history_cell::McpInventoryLoadingCell;
impl ChatWidget {
pub(crate) fn can_change_working_directory(&self, thread_id: ThreadId) -> bool {
let active = &self.transcript.active_cell;
self.thread_id == Some(thread_id)
&& !self.active_side_conversation
&& !self.blocks_direct_input
&& !self.config.ephemeral
&& self.unified_exec_processes.is_empty()
&& !self.is_user_turn_pending_or_running()
&& !self.input_queue.has_queued_follow_up_messages()
&& self.input_queue.pending_steers.is_empty()
&& active
.as_ref()
.is_none_or(|cell| !cell.as_any().is::<McpInventoryLoadingCell>())
}
pub(super) fn request_working_directory_change(&mut self, path: &str) {
if !self.is_session_configured() {
self.add_error_message(
"The session must start before you can change its working directory.".to_string(),
);
return;
}
let Some(thread_id) = self.thread_id else {
return;
};
if !self.can_change_working_directory(thread_id) {
let message =
"Changing directories requires an idle primary session without queued input.";
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(message.to_string()),
)));
return;
}
self.app_event_tx.send(AppEvent::ChangeWorkingDirectory {
thread_id,
requested_cwd: PathBuf::from(path),
});
}
}