From 6f39a47bb3b04de4c804187bfbf55edc56939aab Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 13 Sep 2026 18:41:42 +0000 Subject: [PATCH] Add worktree session creation to the agents overview (#45276) ## What changed - Add a configurable `new_worktree` action, bound to `w`, for local sessions with worktree support enabled. - Create the worktree from the cached project default branch, preferring remote `HEAD` and falling back to conventional `main` or `master` refs without fetching. Preserve the selected subdirectory and source checkout edits. - Start and bind a blank session in the new worktree, preserving the selected approval reviewer without sending an initial turn. - Show creation progress, pause conflicting actions, clean up unclaimed clean worktrees, and report retained checkout paths when session startup fails. Clear pending creation state on reconnect. ## Testing Add coverage for default-branch selection, source checkout preservation, session ownership and approval settings, cleanup, startup failures, reconnect state, and busy-state rendering. Extend Git environment isolation coverage and test unrelated non-UTF-8 refs. GitOrigin-RevId: 771ddc12d04dbf0d12e77e9c96c8386cf31e67c5 --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 + codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/agents_overview.rs | 1 + codex-rs/tui/src/app/agents_overview_new.rs | 181 ++++++++++++- .../tui/src/app/agents_overview_render.rs | 19 +- codex-rs/tui/src/app/agents_overview_tests.rs | 28 +++ codex-rs/tui/src/app/agents_overview_view.rs | 13 +- codex-rs/tui/src/app/event_dispatch.rs | 22 ++ codex-rs/tui/src/app/reconnect.rs | 11 + ...tions__agents_custom_lifecycle_chords.snap | 6 +- ...view__tests__agents_overview_markdown.snap | 4 +- ...__agents_overview_markdown_long_lines.snap | 4 +- ...tests__agents_overview_markdown_table.snap | 4 +- ...tests__agents_overview_model_grouping.snap | 4 +- ...__tests__older_server_narrow_overview.snap | 4 +- ...sts__overview_empty_prompt_right_hint.snap | 2 +- ...view__tests__overview_right_open_hint.snap | 2 +- ...overview_worktree_creation_busy_state.snap | 28 +++ ...__tests__usage__agents_overview_usage.snap | 4 +- .../src/app/tests/active_reconnect_tests.rs | 14 ++ .../tests/background_task_defaults_tests.rs | 238 +++++++++++++++++- .../tui/src/app/tests/disconnect_tests.rs | 4 +- ...ommand_center_retained_worktree_error.snap | 10 + ...daemon_command_center_vanished_rename.snap | 4 +- ...__agents_command_center_after_archive.snap | 4 +- codex-rs/tui/src/app_event.rs | 5 + codex-rs/tui/src/keymap.rs | 12 + codex-rs/tui/src/keymap/bindings.rs | 1 + codex-rs/tui/src/keymap/conflict_tests.rs | 8 +- codex-rs/tui/src/keymap_setup/actions.rs | 2 + ...ents_overview__tests__agents_overview.snap | 4 +- ...iew__tests__agents_overview_attention.snap | 4 +- ...__tests__agents_overview_empty_narrow.snap | 6 +- ...__tests__agents_overview_last_message.snap | 4 +- ..._tests__agents_overview_live_activity.snap | 4 +- ...ts__agents_overview_worktree_grouping.snap | 2 +- ...__tests__keymap_picker_agents_actions.snap | 1 + ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...up__tests__keymap_picker_global_chord.snap | 2 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- ...setup__tests__keymap_question_actions.snap | 2 +- codex-rs/worktree/src/git.rs | 45 ++++ codex-rs/worktree/src/git_tests.rs | 30 +++ codex-rs/worktree/src/lib.rs | 1 + codex-rs/worktree/tests/worktree.rs | 22 +- 49 files changed, 734 insertions(+), 57 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_worktree_creation_busy_state.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_retained_worktree_error.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index b983b9a121..a1a6550b18 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -439,6 +439,8 @@ pub struct TuiAgentsKeymap { pub search: Option, /// Open a new session in the selected checkout. pub new_task: Option, + /// Open a new session in a worktree from the project default branch. + pub new_worktree: Option, /// Rename the selected task. pub rename: Option, /// Stop the selected running task. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c8bae1fb71..0091c9f67a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4090,6 +4090,7 @@ "delete": null, "hide": null, "new_task": null, + "new_worktree": null, "rename": null, "resume": null, "search": null, @@ -4431,6 +4432,14 @@ ], "description": "Open a new session in the selected checkout." }, + "new_worktree": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open a new session in a worktree from the project default branch." + }, "rename": { "allOf": [ { @@ -4932,6 +4941,7 @@ "delete": null, "hide": null, "new_task": null, + "new_worktree": null, "rename": null, "resume": null, "search": null, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index f7e0090fb6..97303cb72a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3,6 +3,7 @@ //! This module owns the `App` struct, shared imports, and the high-level run loop that coordinates //! the focused app submodules. +pub(crate) use self::agents_overview::PendingWorktree; use crate::AppServerTarget; use crate::app_backtrack::BacktrackState; use crate::app_command::AppCommand; diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 049c1a9989..8b60c908aa 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -3,6 +3,7 @@ #[path = "agents_overview_new.rs"] mod new; +pub(crate) use new::PendingWorktree; #[path = "agents_overview_errors.rs"] mod errors; diff --git a/codex-rs/tui/src/app/agents_overview_new.rs b/codex-rs/tui/src/app/agents_overview_new.rs index a840e087e0..1d151f232b 100644 --- a/codex-rs/tui/src/app/agents_overview_new.rs +++ b/codex-rs/tui/src/app/agents_overview_new.rs @@ -1,21 +1,82 @@ //! Creates empty sessions from the command center without interrupting other agents. +//! Worktrees start at the repository default branch and bind only the new session. use super::*; +/// Owns a freshly created checkout until the UI accepts its completion event. +/// Dropping a cancelled worker or undelivered event removes only a clean checkout. +#[derive(Debug)] +pub(crate) struct PendingWorktree { + pub(in crate::app) manager: codex_worktree::WorktreeManager, + pub(in crate::app) checkout: Option, +} + +impl Drop for PendingWorktree { + fn drop(&mut self) { + if let Some(checkout) = &self.checkout { + let result = dunce::canonicalize(&checkout.root) + .map_err(anyhow::Error::from) + .and_then(|root| self.manager.remove(&checkout.source_cwd, &root)); + if let Err(error) = result { + tracing::error!(path = %checkout.root.display(), %error, "Could not remove unclaimed worktree"); + } + } + } +} + impl App { + fn agents_overview_retained_worktree_error( + &mut self, + checkout: &codex_worktree::ManagedWorktree, + reason: impl std::fmt::Display, + ) { + self.add_agents_overview_error(format!("{reason} A checkout was retained at {}; remove it with `git worktree remove ` from the source repository if it is no longer needed.", checkout.root.display())); + } + pub(in crate::app) async fn new_agents_overview_session( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, cwd: Option, + ) -> Result { + // Keep the large session-start future off the TUI's stack in dev builds. + Box::pin( + self.start_agents_overview_session( + tui, app_server, cwd, /*managed_worktree*/ None, + ), + ) + .await + } + + pub(in crate::app) async fn start_agents_overview_session( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + cwd: Option, + managed_worktree: Option<( + codex_worktree::WorktreeManager, + codex_worktree::ManagedWorktree, + )>, ) -> Result { if self.reconnect.offline || self.windows_sandbox_blocks_thread_switch() { + if let Some((_, checkout)) = &managed_worktree { + self.agents_overview_retained_worktree_error( + checkout, + "Could not start the session.", + ); + } return Ok(AppRunControl::Continue); } let Some((config, remote_cwd)) = self .agents_overview_session_config(tui, app_server, cwd) .await else { + if let Some((_, checkout)) = &managed_worktree { + self.agents_overview_retained_worktree_error( + checkout, + "Could not load the new session settings.", + ); + } return Ok(AppRunControl::Continue); }; let selected_profile = self.chat_widget.thread_id().and_then(|thread_id| { @@ -33,7 +94,7 @@ impl App { display_label: active.id, }) }); - let started = match app_server + let result = app_server .start_thread_with_session_start_source( &crate::local_settings::LocalSettings::from(&config), &config, @@ -41,11 +102,18 @@ impl App { remote_cwd.as_deref(), selected_profile.as_ref(), ) - .await - { + .await; + let started = match result { Ok(started) => started, Err(error) => { - self.add_agents_overview_error(format!("Failed to start session: {error}")); + if let Some((_, checkout)) = &managed_worktree { + self.agents_overview_retained_worktree_error( + checkout, + format!("Failed to start session: {error}"), + ); + } else { + self.add_agents_overview_error(format!("Failed to start session: {error}")); + } return Ok(AppRunControl::Continue); } }; @@ -55,17 +123,120 @@ impl App { .selected_permission_profiles .insert(thread_id, selected.profile_id); } + if let Some((manager, checkout)) = &managed_worktree { + let result = + if crate::session_resume::cwds_differ(started.session.cwd.as_path(), &checkout.cwd) + { + Err(anyhow::anyhow!( + "The server did not apply the worktree directory." + )) + } else { + manager.bind_thread(&checkout.root, &thread_id.to_string()) + }; + if let Err(error) = result { + let _ = app_server.thread_unsubscribe(thread_id).await; + self.agents_overview_retained_worktree_error(checkout, error); + return Ok(AppRunControl::Continue); + } + } self.agents_overview .blank_sessions .insert(thread_id, started.clone()); - // Preserve running agents and unsent input without sending an initial turn. + // Use the dashboard's existing attachment path, which preserves running agents + // and unsent input in the previous session. Do not send an initial turn. let control = self .attach_agents_overview_thread(tui, app_server, thread_id, Some((config, started))) .await?; if self.current_displayed_thread_id() != Some(thread_id) { self.agents_overview.blank_sessions.remove(&thread_id); let _ = app_server.thread_unsubscribe(thread_id).await; + if let Some((_, checkout)) = &managed_worktree { + self.agents_overview_retained_worktree_error( + checkout, + "Could not open the new session.", + ); + } } Ok(control) } + + pub(in crate::app) async fn new_agents_overview_worktree( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + cwd: Option, + ) { + if self.reconnect.offline + || self.pending_managed_worktree_creation + || self.windows_sandbox_blocks_thread_switch() + { + return; + } + if !self.config.features.enabled(Feature::Worktrees) + || crate::uses_remote_workspace_or_environment( + &self.app_server_target, + self.environment_manager.as_ref(), + ) + { + self.add_agents_overview_error( + "Managed worktrees require local worktree support.".to_string(), + ); + return; + } + let Some((config, _)) = self + .agents_overview_session_config(tui, app_server, cwd) + .await + else { + return; + }; + let setup = async { + anyhow::ensure!( + !config.active_project.is_untrusted(), + "The source project is not trusted." + ); + let host = crate::legacy_core::config::load_config_toml_with_layer_stack( + &self.config.codex_home, + /*cwd*/ None, + Vec::new(), + codex_config::ConfigLoadOptions::default(), + ) + .await?; + let settings = codex_worktree::WorktreeSettings::for_cli( + &self.config.codex_home, + host.config_toml.desktop.as_ref(), + )?; + anyhow::Ok(codex_worktree::WorktreeManager::new(settings)) + } + .await; + let manager = match setup { + Ok(value) => value, + Err(error) => return self.add_agents_overview_error(error.to_string()), + }; + self.pending_managed_worktree_creation = true; + self.agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .creating_worktree = true; + let sender = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + codex_worktree::default_worktree_base(config.cwd.as_path()) + .and_then(|base| { + manager.create(&codex_worktree::CreateWorktree { + source_cwd: config.cwd.to_path_buf(), + base: Some(base), + }) + }) + .map(|checkout| PendingWorktree { + manager, + checkout: Some(checkout), + }) + .map_err(|error| error.to_string()) + }) + .await + .unwrap_or_else(|error| Err(format!("Worktree creation task failed: {error}"))); + sender.send(AppEvent::AgentsOverviewWorktreeCreated(result)); + }); + } } diff --git a/codex-rs/tui/src/app/agents_overview_render.rs b/codex-rs/tui/src/app/agents_overview_render.rs index 94f4c5ddc7..28133e5ddd 100644 --- a/codex-rs/tui/src/app/agents_overview_render.rs +++ b/codex-rs/tui/src/app/agents_overview_render.rs @@ -27,6 +27,8 @@ impl AgentsOverviewView { )) } else if state.connection_notice.is_some() { Some("ctrl+c quit · actions paused until the list is refreshed".into()) + } else if state.creating_worktree { + Some("Creating worktree… ctrl+c quit".into()) } else { None }; @@ -40,9 +42,15 @@ impl AgentsOverviewView { if width < 24 { let hints = [ ("new_task", &self.agents_keymap.new_task, "new"), + ( + "new_worktree", + &self.agents_keymap.new_worktree, + "new worktree", + ), ("search", &self.agents_keymap.search, "search"), ] .into_iter() + .filter(|(action, _, _)| *action != "new_worktree" || self.worktrees_enabled) .filter_map(|(action, bindings, label)| { self.agents_keymap .primary_hint(action, bindings) @@ -65,6 +73,7 @@ impl AgentsOverviewView { &self.agents_keymap.resume, &self.agents_keymap.search, &self.agents_keymap.new_task, + &self.agents_keymap.new_worktree, &self.agents_keymap.rename, &self.agents_keymap.stop, &self.agents_keymap.archive, @@ -125,6 +134,12 @@ impl AgentsOverviewView { "new", true, ); + add_hint( + self.agents_keymap + .primary_hint("new_worktree", &self.agents_keymap.new_worktree), + "new worktree", + self.worktrees_enabled, + ); add_hint( self.agents_keymap .primary_hint("search", &self.agents_keymap.search), @@ -254,7 +269,9 @@ impl Renderable for AgentsOverviewView { } }); let attention = format!("{needs_you} need input"); - if let Some(notice) = self.state().connection_notice { + if self.state().creating_worktree { + Line::from("Creating worktree…".cyan()).render(inset(summary), buf); + } else if let Some(notice) = self.state().connection_notice { Line::from(notice.cyan()).render(inset(summary), buf); } else if self.state().refresh_failed { Line::from("Error loading tasks".red()).render(inset(summary), buf); diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index b958e6dc52..0aa841741a 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -1,6 +1,25 @@ use super::super::agents_overview_view::AgentsOverviewGrouping; use super::*; +#[tokio::test] +async fn overview_worktree_creation_busy_state() { + let mut app = make_test_app().await; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + app.agents_overview + .view_state + .lock() + .unwrap() + .creating_worktree = true; + let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + for key in ['n', 'w', 'o', 'r', 'x', 'h', 'a'] { + view.handle_key_event(KeyCode::Char(key).into()); + } + assert!(rx.try_recv().is_err()); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80)); +} + #[tokio::test] async fn overview_thread_colors_match_footer_and_respect_color_suppression() { let mut app = make_test_app().await; @@ -2630,6 +2649,7 @@ async fn command_center_escape_cancels_editors_and_never_closes_list() { #[tokio::test] async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone() { let mut app = make_test_app().await; + app.config.features.enable(Feature::Worktrees).unwrap(); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); app.app_event_tx = AppEventSender::new(tx); let id = ThreadId::new(); @@ -2646,6 +2666,10 @@ async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone( assert!( matches!(rx.try_recv(), Ok(AppEvent::NewAgentsOverviewSession { cwd: Some(cwd) }) if cwd == target.cwd) ); + view.handle_key_event(KeyCode::Char('w').into()); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::NewAgentsOverviewWorktree { cwd: Some(cwd) }) if cwd == target.cwd) + ); view.handle_key_event(KeyCode::Char('g').into()); } view.handle_key_event(KeyCode::Char('r').into()); @@ -2663,4 +2687,8 @@ async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone( rx.try_recv(), Ok(AppEvent::NewAgentsOverviewSession { cwd: None }) )); + app.config.features.disable(Feature::Worktrees).unwrap(); + let mut disabled = app.agents_overview_view(vec![target], Some(id)); + disabled.handle_key_event(KeyCode::Char('w').into()); + assert!(rx.try_recv().is_err()); } diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 1d3f8a2afb..5965b6516a 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -134,6 +134,7 @@ impl AgentsOverviewProjectGroup { pub(super) struct AgentsOverviewViewState { pub(super) input: String, pub(super) key_chord_hint: Option>, + pub(super) creating_worktree: bool, pub(super) refresh_failed: bool, pub(super) connection_notice: Option<&'static str>, pub(super) server_version_notice: Option, @@ -160,6 +161,7 @@ pub(super) struct AgentsOverviewView { app_event_tx: AppEventSender, keymap: ListKeymap, agents_keymap: AgentsKeymap, + worktrees_enabled: bool, } impl AgentsOverviewView { @@ -189,6 +191,7 @@ impl AgentsOverviewView { app_event_tx, keymap: keymap.list, agents_keymap: keymap.agents, + worktrees_enabled, }; view.state().completion = None; let visible = view.visible_indices(); @@ -585,7 +588,7 @@ impl BottomPaneView for AgentsOverviewView { return; } - if self.state().connection_notice.is_some() + if (self.state().connection_notice.is_some() || self.state().creating_worktree) && self.keymap.action_for(key) != Some(ListAction::Cancel) { match self.keymap.action_for(key) { @@ -615,6 +618,14 @@ impl BottomPaneView for AgentsOverviewView { }); return; } + if self.agents_keymap.new_worktree.is_pressed(key) { + if self.worktrees_enabled { + self.app_event_tx.send(AppEvent::NewAgentsOverviewWorktree { + cwd: self.selected_row().map(|row| row.thread.cwd.clone()), + }); + } + return; + } if self.agents_keymap.rename.is_pressed(key) { if let Some(row) = self.selected_row() { let mut state = self.state(); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 3e71d23e8c..7e39539451 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -37,6 +37,7 @@ impl App { | AppEvent::CommitRealtimeTranscriptHistory | AppEvent::ResetTranscriptForThreadSwitch | AppEvent::ManagedWorktreeCreated(_) + | AppEvent::AgentsOverviewWorktreeCreated(_) | AppEvent::AppendMessageHistoryEntry { .. } | AppEvent::BeginInitialHistoryReplayBuffer | AppEvent::BeginThreadSwitchHistoryReplayBuffer @@ -2310,6 +2311,9 @@ impl App { } self.config.approvals_reviewer = policy; self.chat_widget.set_approvals_reviewer(policy); + if let Some(profile) = self.runtime_permission_profile_override.as_mut() { + profile.approvals_reviewer = policy; + } self.sync_active_thread_permission_settings_to_cached_session() .await; if let Err(err) = crate::config_update::write_config_batch( @@ -2447,6 +2451,24 @@ impl App { AppEvent::NewAgentsOverviewSession { cwd } => { return Box::pin(self.new_agents_overview_session(tui, app_server, cwd)).await; } + AppEvent::NewAgentsOverviewWorktree { cwd } => { + Box::pin(self.new_agents_overview_worktree(tui, app_server, cwd)).await; + } + AppEvent::AgentsOverviewWorktreeCreated(result) => { + self.pending_managed_worktree_creation = false; + self.agents_overview.view_state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).creating_worktree = false; + match result { + Ok(mut pending) => { + let Some(checkout) = pending.checkout.take() else { + return Ok(AppRunControl::Continue); + }; + let manager = pending.manager.clone(); + let cwd = AbsolutePathBuf::try_from(checkout.cwd.clone())?; + return Box::pin(self.start_agents_overview_session(tui, app_server, Some(cwd), Some((manager, checkout)))).await; + } + Err(error) => self.add_agents_overview_error(error), + } + } AppEvent::RenameAgentsOverviewThread { thread_id, name } => { match app_server.thread_set_name(thread_id, name.clone()).await { Ok(()) => { diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index b811bd54b1..329e959f37 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -259,6 +259,17 @@ impl App { let (tx, rx) = mpsc::unbounded_channel(); self.app_event_tx = AppEventSender::new(tx); *app_event_rx = rx; + { + let mut state = self + .agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.creating_worktree { + state.creating_worktree = false; + self.pending_managed_worktree_creation = false; + } + } self.agent_navigation.picker_refresh = None; self.last_subagent_backfill_attempt = None; self.rate_limit_refresh_state.invalidate_recovery(); diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap index b17072f716..ccf9a2877e 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap @@ -23,6 +23,6 @@ expression: "render_bottom_popup(&app.chat_widget,\n48).replace(&test_path_displ ↑↓ navigate o resume enter open n new - f search g group: project r rename x stop - f5 f8 hide f5 f6 archive f5 f7 delete - ctrl+c quit + w new worktree f search g group: project + r rename x stop f5 f8 hide f5 f6 archive + f5 f7 delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap index 5387011184..a5fdfdd9c2 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap @@ -40,5 +40,5 @@ expression: "cached.replace(&format!(\"{project} 1\"),\n&format!(\"/tmp/project " │ " " │ " " │ " -" ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide " -" a archive del delete ctrl+c quit " +" ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename " +" x stop h hide a archive del delete ctrl+c quit " diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap index 46cfc08488..d4149a0e94 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap @@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ … │ │ - ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide - a archive del delete ctrl+c quit + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap index ebae5141bd..6234eb0161 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap @@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ … │ │ - ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide - a archive del delete ctrl+c quit + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_model_grouping.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_model_grouping.snap index d2ea49a4a9..ed924d7563 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_model_grouping.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_model_grouping.snap @@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n100).replace(&test_path_disp │ │ │ - ↑↓ navigate o resume enter open n new f search g group: model r rename x stop h hide a archive - del delete ctrl+c quit + ↑↓ navigate o resume enter open n new w new worktree f search g group: model r rename x stop + h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_narrow_overview.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_narrow_overview.snap index 6828109c76..4fcf2455eb 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_narrow_overview.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_narrow_overview.snap @@ -20,9 +20,9 @@ expression: "render_bottom_popup(&app.chat_widget, 12)" - - n new + w new + worktree f search ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_empty_prompt_right_hint.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_empty_prompt_right_hint.snap index 3476b2d35b..21a2fa6d88 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_empty_prompt_right_hint.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_empty_prompt_right_hint.snap @@ -2,4 +2,4 @@ source: tui/src/app/agents_overview_tests.rs expression: rendered.lines().last().unwrap() --- - a archive del delete ctrl+c quit + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_right_open_hint.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_right_open_hint.snap index 066edac8b9..23f26d9805 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_right_open_hint.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_right_open_hint.snap @@ -2,4 +2,4 @@ source: tui/src/app/agents_overview_tests.rs expression: "rendered.lines().find(|line| line.contains(\"open\")).unwrap()" --- - ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_worktree_creation_busy_state.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_worktree_creation_busy_state.snap new file mode 100644 index 0000000000..31b22fda4f --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_worktree_creation_busy_state.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Agent command center + Creating worktree… + ──────────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + + + + + + Creating worktree… ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap index 2f2063786e..71287f8cc6 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap @@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ Review parser │ │ - ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide - a archive del delete ctrl+c quit + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index 3a457de0cc..560d652dbf 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -238,6 +238,12 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() -> .note_turn_finished(&TurnStatus::Completed, before_disconnect); } app.schedule_recap_check(id, Instant::now()); + app.pending_managed_worktree_creation = true; + app.agents_overview + .view_state + .lock() + .unwrap() + .creating_worktree = true; let old_sender = app.app_event_tx.clone(); let connected = reconnect( app.app_server_target.clone(), @@ -257,6 +263,14 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() -> app.finish_reconnect(&mut tui, &mut session, &mut events, connected, "2.1.0") .await?; assert!(app.pending_server_profiles.is_empty()); + assert!(!app.pending_managed_worktree_creation); + assert!( + !app.agents_overview + .view_state + .lock() + .unwrap() + .creating_worktree + ); assert!(!app.reconnect.offline); assert!(!app.thread_unavailable(id)); assert_eq!(app.last_subagent_backfill_attempt, None); diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index 406ddf3e86..a996943e5d 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -1,4 +1,4 @@ -//! Request-level coverage for command-center session defaults. +//! Request-level coverage for command-center session defaults and worktree creation. use super::*; use crate::app::agents_overview::AGENTS_OVERVIEW_VIEW_ID; @@ -719,3 +719,239 @@ async fn command_center_new_restores_blank_drafts_and_builtin_permissions() -> R proxy.await??; Ok(()) } + +#[tokio::test] +async fn command_center_new_checkout_and_worktree_preserve_source_and_default_branch() -> Result<()> +{ + use codex_worktree::CreateWorktree; + use codex_worktree::WorktreeManager; + use codex_worktree::WorktreeSettings; + for (remote_only, stale_local) in [(false, false), (true, false), (false, true)] { + let directory = tempdir()?; + let source = directory.path().join("project"); + std::fs::create_dir_all(source.join("subdir"))?; + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .args(["-c", "commit.gpgSign=false"]) + .args(args) + .current_dir(&source) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init", "-b", "main"]); + std::fs::write(source.join("subdir/file"), "default branch")?; + git(&["add", "."]); + git(&["commit", "-m", "default"]); + if remote_only { + git(&["update-ref", "refs/remotes/origin/release/default", "HEAD"]); + git(&[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/release/default", + ]); + } + git(&["checkout", "-b", "feature"]); + std::fs::write(source.join("subdir/file"), "feature commit")?; + git(&["commit", "-am", "feature"]); + if remote_only { + git(&["branch", "-D", "main"]); + } + if stale_local { + git(&["update-ref", "refs/remotes/origin/main", "main"]); + git(&[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + ]); + git(&["branch", "-f", "main", "feature"]); + } + let home = directory.path().join("home"); + std::fs::create_dir(&home)?; + std::fs::write( + home.join("config.toml"), + format!( + "approvals_reviewer = \"auto_review\"\nsandbox_mode = \"workspace-write\"\napproval_policy = \"on-request\"\nwindows.sandbox = \"unelevated\"\n[projects.{:?}]\ntrust_level = \"trusted\"\n", + source.canonicalize()?.display().to_string(), + ), + )?; + let manager = + WorktreeManager::new(WorktreeSettings::for_cli(&home, /*desktop*/ None).unwrap()); + let selected = manager + .create(&CreateWorktree { + source_cwd: source.join("subdir"), + base: Some("feature".into()), + }) + .unwrap(); + manager + .bind_thread(&selected.root, "existing-owner") + .unwrap(); + std::fs::write(selected.cwd.join("file"), "dirty edit")?; + std::fs::write(selected.cwd.join("untracked"), "untracked")?; + for receiver_closed in [true, false] { + let unclaimed = manager + .create(&CreateWorktree { + source_cwd: source.join("subdir"), + base: Some("feature".into()), + }) + .unwrap(); + let root = unclaimed.root.clone(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let receiver = (!receiver_closed).then_some(rx); + AppEventSender::new(tx).send(AppEvent::AgentsOverviewWorktreeCreated(Ok( + PendingWorktree { + manager: manager.clone(), + checkout: Some(unclaimed), + }, + ))); + drop(receiver); + assert!(!root.exists()); + assert_eq!(manager.list(&source).unwrap().len(), 1); + } + let (mut app, mut events, _) = make_test_app_with_channels().await; + app.config.codex_home = home.clone().abs(); + app.config.cwd = selected.cwd.clone().abs(); + app.harness_overrides.cwd = Some(selected.cwd.clone()); + app.cli_kv_overrides + .push(("features.worktrees".into(), TomlValue::Boolean(true))); + app.config.features.enable(Feature::Worktrees)?; + trust_launch_folder(&mut app); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + let mut tui = make_test_tui()?; + let new_session = app.new_agents_overview_session( + &mut tui, + &mut server, + Some(selected.cwd.clone().abs()), + ); + let size = std::mem::size_of_val(&new_session); + assert!( + size < 64 * 1024, + "new-session wrapper future is {size} bytes" + ); + new_session.await?; + let first = app.chat_widget.thread_id().expect("new checkout session"); + assert!(!crate::session_resume::cwds_differ( + app.chat_widget.config_ref().cwd.as_path(), + &selected.cwd + )); + assert_eq!( + manager.owner(&selected.root).unwrap(), + Some("existing-owner".into()) + ); + // Exercise the menu's ordered events, not the separate profile-selection API. + app.chat_widget + .set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); + app.chat_widget.open_permissions_popup(); + app.chat_widget.handle_key_event(KeyCode::Up.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + while let Ok(event) = events.try_recv() { + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + } + assert_eq!( + app.chat_widget.config_ref().approvals_reviewer, + ApprovalsReviewer::User + ); + app.new_agents_overview_worktree(&mut tui, &mut server, Some(selected.cwd.clone().abs())) + .await; + assert!(app.pending_managed_worktree_creation); + let result = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Some(AppEvent::AgentsOverviewWorktreeCreated(result)) = events.recv().await { + break result; + } + } + }) + .await? + .map_err(|message| color_eyre::eyre::eyre!(message))?; + let checkout = result.checkout.as_ref().unwrap().clone(); + assert_eq!( + std::fs::read_to_string(checkout.cwd.join("file"))?, + "default branch" + ); + assert!(!checkout.cwd.join("untracked").exists()); + assert_eq!(checkout.cwd, checkout.root.join("subdir")); + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::AgentsOverviewWorktreeCreated(Ok(result)), + )) + .await?; + let second = app.chat_widget.thread_id().expect("new worktree session"); + assert_ne!(first, second); + assert_eq!( + recorded_params(&requests, "thread/start").last().unwrap()["approvalsReviewer"], + serde_json::json!("user") + ); + assert_eq!( + app.chat_widget.config_ref().approvals_reviewer, + ApprovalsReviewer::User + ); + assert_eq!( + manager.owner(&checkout.root).unwrap(), + Some(second.to_string()) + ); + assert_eq!(recorded_params(&requests, "turn/start").len(), 0); + assert_eq!( + std::fs::read_to_string(selected.cwd.join("file"))?, + "dirty edit" + ); + server.shutdown().await?; + proxy.await??; + if !remote_only && !stale_local { + let unused = manager + .create(&CreateWorktree { + source_cwd: source.join("subdir"), + base: Some("main".into()), + }) + .unwrap(); + let (mut failed_server, _, failed_proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::ThreadStartFails, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.start_agents_overview_session( + &mut tui, + &mut failed_server, + Some(unused.cwd.clone().abs()), + Some((manager.clone(), unused.clone())), + ) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(second)); + assert_eq!(manager.owner(&unused.root).unwrap(), None); + assert!(unused.root.exists()); + let rendered = crate::chatwidget::tests::helpers::render_bottom_popup( + &app.chat_widget, + /*width*/ 500, + ); + insta::assert_snapshot!( + "command_center_retained_worktree_error", + rendered.replace(&unused.root.display().to_string(), "") + ); + failed_server.shutdown().await?; + failed_proxy.await??; + } + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index dec6448256..b1ed8d1e6b 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -236,7 +236,9 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< assert!( !std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( event, - AppEvent::NewAgentsOverviewSession { .. } | AppEvent::OpenResumePicker + AppEvent::NewAgentsOverviewSession { .. } + | AppEvent::NewAgentsOverviewWorktree { .. } + | AppEvent::OpenResumePicker )) ); assert_snapshot!( diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_retained_worktree_error.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_retained_worktree_error.snap new file mode 100644 index 0000000000..c8d6f282a4 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_retained_worktree_error.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/app/tests/background_task_defaults_tests.rs +expression: "rendered.replace(&unused.root.display().to_string(), \"\")" +--- + Unable to complete action + Failed to start session: thread/start failed during TUI bootstrap: thread/start failed: replacement unavailable (code -32603) A checkout was retained at ; remove it with `git worktree remove ` from the source repository if it is no longer needed. + +› 1. Return to command center + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap index d2fd87fcf4..e45ce60aba 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap @@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide a archive - del delete ctrl+c quit + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename x stop + h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap index 4b5d8bec0d..7d1c018a64 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap @@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide a archive - del delete ctrl+c quit + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename x stop + h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index cd699d77ca..6235aa995f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -287,6 +287,11 @@ pub(crate) enum AppEvent { NewAgentsOverviewSession { cwd: Option, }, + /// Create an empty session in a worktree from the selected project's default branch. + NewAgentsOverviewWorktree { + cwd: Option, + }, + AgentsOverviewWorktreeCreated(Result), /// Rename a task directly from the shared dashboard. RenameAgentsOverviewThread { thread_id: ThreadId, diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 41a3a70f73..40404e8339 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -409,6 +409,7 @@ pub(crate) struct AgentsKeymap { pub(crate) resume: Vec, pub(crate) search: Vec, pub(crate) new_task: Vec, + pub(crate) new_worktree: Vec, pub(crate) rename: Vec, pub(crate) stop: Vec, pub(crate) archive: Vec, @@ -1278,6 +1279,7 @@ impl RuntimeKeymap { resume: resolve_local!(keymap, defaults, agents, resume), search: resolve_local!(keymap, defaults, agents, search), new_task: resolve_local!(keymap, defaults, agents, new_task), + new_worktree: resolve_local!(keymap, defaults, agents, new_worktree), rename: resolve_local!(keymap, defaults, agents, rename), stop: resolve_local!(keymap, defaults, agents, stop), archive: resolve_local!(keymap, defaults, agents, archive), @@ -1292,6 +1294,11 @@ impl RuntimeKeymap { (keymap.agents.resume.as_ref(), &mut agents.resume, "o"), (keymap.agents.search.as_ref(), &mut agents.search, "f"), (keymap.agents.new_task.as_ref(), &mut agents.new_task, "n"), + ( + keymap.agents.new_worktree.as_ref(), + &mut agents.new_worktree, + "w", + ), (keymap.agents.rename.as_ref(), &mut agents.rename, "r"), (keymap.agents.stop.as_ref(), &mut agents.stop, "x"), (keymap.agents.archive.as_ref(), &mut agents.archive, "a"), @@ -1446,6 +1453,10 @@ impl RuntimeKeymap { (keymap.agents.resume.as_ref(), &mut agents.resume), (keymap.agents.search.as_ref(), &mut agents.search), (keymap.agents.new_task.as_ref(), &mut agents.new_task), + ( + keymap.agents.new_worktree.as_ref(), + &mut agents.new_worktree, + ), (keymap.agents.rename.as_ref(), &mut agents.rename), (keymap.agents.stop.as_ref(), &mut agents.stop), (keymap.agents.archive.as_ref(), &mut agents.archive), @@ -1813,6 +1824,7 @@ impl RuntimeKeymap { resume: default_bindings![plain(KeyCode::Char('o'))], search: default_bindings![plain(KeyCode::Char('f'))], new_task: default_bindings![plain(KeyCode::Char('n'))], + new_worktree: default_bindings![plain(KeyCode::Char('w'))], rename: default_bindings![plain(KeyCode::Char('r'))], stop: default_bindings![plain(KeyCode::Char('x'))], archive: default_bindings![plain(KeyCode::Char('a'))], diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 30785ac858..44c6ea7477 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -396,6 +396,7 @@ define_runtime_action_bindings! { resume, search, new_task, + new_worktree, rename, stop, archive, diff --git a/codex-rs/tui/src/keymap/conflict_tests.rs b/codex-rs/tui/src/keymap/conflict_tests.rs index 47b65a3c81..068d4a4300 100644 --- a/codex-rs/tui/src/keymap/conflict_tests.rs +++ b/codex-rs/tui/src/keymap/conflict_tests.rs @@ -40,7 +40,12 @@ See the Codex keymap documentation for supported actions and examples." #[test] fn new_agents_defaults_preserve_existing_custom_bindings() { - for (action, alias) in [("archive", "a"), ("delete", "delete"), ("hide", "h")] { + for (action, alias) in [ + ("archive", "a"), + ("delete", "delete"), + ("hide", "h"), + ("new_worktree", "w"), + ] { for (context, existing, suffix) in [ ("agents", "stop", ""), ("list", "move_down", ""), @@ -65,6 +70,7 @@ fn new_agents_defaults_preserve_existing_custom_bindings() { "archive" => &runtime.agents.archive, "delete" => &runtime.agents.delete, "hide" => &runtime.agents.hide, + "new_worktree" => &runtime.agents.new_worktree, _ => unreachable!(), }; assert!( diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index a0aa2a8d68..0c809ea1ff 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -216,6 +216,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("agents", "Agents", "resume", "Open the session resume picker."), action("agents", "Agents", "search", "Search the available agent tasks."), action("agents", "Agents", "new_task", "Open a new session in the selected checkout."), + action("agents", "Agents", "new_worktree", "Open a new session in a worktree from the project default branch."), action("agents", "Agents", "rename", "Rename the selected task."), action("agents", "Agents", "stop", "Stop the selected running task."), action("agents", "Agents", "archive", "Archive the selected task and its child agents."), @@ -396,6 +397,7 @@ pub(super) fn binding_slot<'a>( ("agents", "resume") => Some(&mut keymap.agents.resume), ("agents", "search") => Some(&mut keymap.agents.search), ("agents", "new_task") => Some(&mut keymap.agents.new_task), + ("agents", "new_worktree") => Some(&mut keymap.agents.new_worktree), ("agents", "rename") => Some(&mut keymap.agents.rename), ("agents", "stop") => Some(&mut keymap.agents.stop), ("agents", "archive") => Some(&mut keymap.agents.archive), diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap index 1c9a33e535..82b8f169f4 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap @@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ │ │ - ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide - a archive del delete ctrl+c quit + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attention.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attention.snap index 6ee8cb3113..8e2cc74efd 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attention.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attention.snap @@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ Repair authentication │ │ - ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide - a archive del delete ctrl+c quit + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap index f5d9f67550..68226d2f8d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap @@ -22,7 +22,7 @@ expression: rendered - ↑↓ navigate o resume enter open n new - f search g group: project r rename x stop - h hide a archive del delete ctrl+c quit + w new worktree f search g group: project + r rename x stop h hide a archive del delete + ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_last_message.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_last_message.snap index 59994ecbe4..7014305512 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_last_message.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_last_message.snap @@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ Prompt │ Investigate parser │ … - ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide - a archive del delete ctrl+c quit + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_live_activity.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_live_activity.snap index 82f41d52bd..2ea5844295 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_live_activity.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_live_activity.snap @@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ │ │ - ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide - a archive del delete ctrl+c quit + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_worktree_grouping.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_worktree_grouping.snap index c96c8c3f08..a940fbe6db 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_worktree_grouping.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_worktree_grouping.snap @@ -25,4 +25,4 @@ expression: grouping - ↑↓ navigate o resume enter open n new f search g group: project r rename x stop h hide a archive del delete ctrl+c quit + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap index 08b1ac1405..63d3e1cb5b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap @@ -5,6 +5,7 @@ expression: agents Resume | o | Agents resume Resume Open the session resume picker. o Default Search | f | Agents search Search Search the available agent tasks. f Default New Task | n | Agents new_task New Task Open a new session in the selected checkout. n Default +New Worktree | w | Agents new_worktree New Worktree Open a new session in a worktree from the project default branch. w Default Rename | r | Agents rename Rename Rename the selected task. r Default Stop | x | Agents stop Stop Stop the selected running task. x Default Archive | a | Agents archive Archive Archive the selected task and its child agents. a Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index a5a09e4e96..edc15b7906 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 145 actions, 1 customized, 5 unbound. + 146 actions, 1 customized, 5 unbound. [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 6163a996aa..ccad420a19 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 146 actions, 0 customized, 6 unbound. + 147 actions, 0 customized, 6 unbound. [All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 76535cd4da..1161a41966 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,7 +2,7 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (145 selectable) +tab: All (146 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (5) (5 selectable) @@ -11,7 +11,7 @@ tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (69 selectable) tab: Navigation (20 selectable) -tab: Agents (9 selectable) +tab: Agents (10 selectable) tab: Approval (8 selectable) tab: Debug (1 selectable) Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap index 1ce9c49f8c..85bbb42f4c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 145 actions, 1 customized, 6 unbound. + 146 actions, 1 customized, 6 unbound. [All] Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 4c6d34eed2..20cad9e811 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 145 actions, 0 customized, 5 unbound. + 146 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 1c72983594..fbfec35d5e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 145 actions, 0 customized, 5 unbound. + 146 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap index b16b7e0fc9..5041ccfa95 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 145 actions, 0 customized, 5 unbound. + 146 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/worktree/src/git.rs b/codex-rs/worktree/src/git.rs index 18b8863317..00c86e74b9 100644 --- a/codex-rs/worktree/src/git.rs +++ b/codex-rs/worktree/src/git.rs @@ -94,6 +94,51 @@ pub(crate) fn git_path_from_bytes(bytes: &[u8]) -> Result { } } +/// Resolves the cached repository default without fetching or inheriting Git selectors. +/// Prefer a remote HEAD (origin first), then conventional main/master refs. +pub fn default_worktree_base(cwd: &Path) -> Result { + let output = git_output( + cwd, + GitOperation::Metadata, + [ + "for-each-ref", + "--format=%(refname) %(symref)", + "refs/remotes", + "refs/heads", + ], + )?; + // Unrelated refs may contain non-UTF-8 bytes. Decode only the chosen base, + // because CreateWorktree accepts a UTF-8 revision. + let branches = output + .stdout + .split(|byte| *byte == b'\n') + .filter_map(|line| { + let line = line.strip_suffix(b"\r").unwrap_or(line); + let separator = line.iter().position(|byte| *byte == b' ')?; + Some((&line[..separator], &line[separator + 1..])) + }) + .collect::>(); + let base = branches + .iter() + .filter(|(name, target)| name.ends_with(b"/HEAD") && !target.is_empty()) + .min_by_key(|(name, _)| (!name.starts_with(b"refs/remotes/origin/"), *name)) + .map(|(_, target)| *target) + .or_else(|| { + [ + b"refs/remotes/origin/main".as_slice(), + b"refs/remotes/origin/master".as_slice(), + b"refs/heads/main".as_slice(), + b"refs/heads/master".as_slice(), + ] + .into_iter() + .find(|candidate| branches.iter().any(|(branch, _)| branch == candidate)) + }) + .context("Could not determine the project's default branch.")?; + std::str::from_utf8(base) + .context("The project's default branch is not valid UTF-8.") + .map(str::to_owned) +} + fn base_git_command(cwd: &Path) -> Command { let mut command = Command::new("git"); // Git wrappers export repository-local state to their children. Select this diff --git a/codex-rs/worktree/src/git_tests.rs b/codex-rs/worktree/src/git_tests.rs index 805409c5f6..ac565f3dfc 100644 --- a/codex-rs/worktree/src/git_tests.rs +++ b/codex-rs/worktree/src/git_tests.rs @@ -166,3 +166,33 @@ fn write_marker_script(path: &Path, marker: &Path) -> Result<()> { fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; Ok(()) } + +#[cfg(unix)] +#[test] +fn default_base_ignores_unrelated_non_utf8_refs() -> Result<()> { + let (_directory, root) = repository()?; + let head = git_stdout(&root, ["rev-parse", "HEAD"])?; + // Packed refs work even on filesystems that cannot store non-UTF-8 filenames. + fs::write( + root.join(".git/packed-refs"), + [head.as_bytes(), b" refs/heads/unrelated-\xff\n"].concat(), + )?; + assert_eq!(super::default_worktree_base(&root)?, "refs/heads/main"); + setup_git( + &root, + &["update-ref", "refs/remotes/origin/release/stable", "HEAD"], + )?; + setup_git( + &root, + &[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/release/stable", + ], + )?; + assert_eq!( + super::default_worktree_base(&root)?, + "refs/remotes/origin/release/stable" + ); + Ok(()) +} diff --git a/codex-rs/worktree/src/lib.rs b/codex-rs/worktree/src/lib.rs index e701a92ef8..c52bb559ff 100644 --- a/codex-rs/worktree/src/lib.rs +++ b/codex-rs/worktree/src/lib.rs @@ -20,6 +20,7 @@ use std::path::Component; use std::path::Path; use std::path::PathBuf; +pub use git::default_worktree_base; pub use settings::DEFAULT_WORKTREE_KEEP_COUNT; pub use settings::WorktreeSettings; diff --git a/codex-rs/worktree/tests/worktree.rs b/codex-rs/worktree/tests/worktree.rs index 8df546d915..c3c2100549 100644 --- a/codex-rs/worktree/tests/worktree.rs +++ b/codex-rs/worktree/tests/worktree.rs @@ -4,6 +4,7 @@ use codex_worktree::CreateWorktree; use codex_worktree::ManagedWorktree; use codex_worktree::WorktreeManager; use codex_worktree::WorktreeSettings; +use codex_worktree::default_worktree_base; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -434,12 +435,10 @@ fn creation_ignores_inherited_git_environment() { let settings = WorktreeSettings::from_desktop_config(&root.join("codex-home"), /*desktop*/ None) .expect("fixture operation succeeds"); - let worktree = create_worktree( - &WorktreeManager::new(settings), - &source_cwd, - /*base*/ None, - ) - .expect("fixture operation succeeds"); + let base = default_worktree_base(&source_cwd).expect("resolve source default"); + assert_eq!(base, "refs/remotes/origin/source-default"); + let worktree = create_worktree(&WorktreeManager::new(settings), &source_cwd, Some(&base)) + .expect("fixture operation succeeds"); assert_eq!(worktree.source_root, source); assert_eq!(worktree.source_cwd, source_cwd); assert_eq!( @@ -462,6 +461,17 @@ fn creation_ignores_inherited_git_environment() { .expect("fixture operation succeeds"); run_git(&fixture.repository, &["add", "."]); commit(&fixture.repository, "checkout filter attributes"); + for (repository, branch) in [ + (&fixture.repository, "source-default"), + (&other, "other-default"), + ] { + let target = format!("refs/remotes/origin/{branch}"); + run_git(repository, &["update-ref", &target, "HEAD"]); + run_git( + repository, + &["symbolic-ref", "refs/remotes/origin/HEAD", &target], + ); + } let mut child = Command::new(std::env::current_exe().expect("current test executable")); child .args(["--exact", "creation_ignores_inherited_git_environment", "--nocapture"])