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
This commit is contained in:
Eric Traut authored and copyberry committed 2026-09-13 18:53:06 +00:00
1 parent 44b9011611
commit 6f39a47bb3
49 files changed
+734 -57

No files matched your search

+2
View File
@@ -439,6 +439,8 @@ pub struct TuiAgentsKeymap {
pub search: Option<KeybindingsSpec>, pub search: Option<KeybindingsSpec>,
/// Open a new session in the selected checkout. /// Open a new session in the selected checkout.
pub new_task: Option<KeybindingsSpec>, pub new_task: Option<KeybindingsSpec>,
/// Open a new session in a worktree from the project default branch.
pub new_worktree: Option<KeybindingsSpec>,
/// Rename the selected task. /// Rename the selected task.
pub rename: Option<KeybindingsSpec>, pub rename: Option<KeybindingsSpec>,
/// Stop the selected running task. /// Stop the selected running task.
+10
View File
@@ -4090,6 +4090,7 @@
"delete": null, "delete": null,
"hide": null, "hide": null,
"new_task": null, "new_task": null,
"new_worktree": null,
"rename": null, "rename": null,
"resume": null, "resume": null,
"search": null, "search": null,
@@ -4431,6 +4432,14 @@
], ],
"description": "Open a new session in the selected checkout." "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": { "rename": {
"allOf": [ "allOf": [
{ {
@@ -4932,6 +4941,7 @@
"delete": null, "delete": null,
"hide": null, "hide": null,
"new_task": null, "new_task": null,
"new_worktree": null,
"rename": null, "rename": null,
"resume": null, "resume": null,
"search": null, "search": null,
+1
View File
@@ -3,6 +3,7 @@
//! This module owns the `App` struct, shared imports, and the high-level run loop that coordinates //! This module owns the `App` struct, shared imports, and the high-level run loop that coordinates
//! the focused app submodules. //! the focused app submodules.
pub(crate) use self::agents_overview::PendingWorktree;
use crate::AppServerTarget; use crate::AppServerTarget;
use crate::app_backtrack::BacktrackState; use crate::app_backtrack::BacktrackState;
use crate::app_command::AppCommand; use crate::app_command::AppCommand;
+1
View File
@@ -3,6 +3,7 @@
#[path = "agents_overview_new.rs"] #[path = "agents_overview_new.rs"]
mod new; mod new;
pub(crate) use new::PendingWorktree;
#[path = "agents_overview_errors.rs"] #[path = "agents_overview_errors.rs"]
mod errors; mod errors;
+176 -5
View File
@@ -1,21 +1,82 @@
//! Creates empty sessions from the command center without interrupting other agents. //! 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::*; 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<codex_worktree::ManagedWorktree>,
}
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 { 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 <checkout-path>` from the source repository if it is no longer needed.", checkout.root.display()));
}
pub(in crate::app) async fn new_agents_overview_session( pub(in crate::app) async fn new_agents_overview_session(
&mut self, &mut self,
tui: &mut tui::Tui, tui: &mut tui::Tui,
app_server: &mut AppServerSession, app_server: &mut AppServerSession,
cwd: Option<AbsolutePathBuf>, cwd: Option<AbsolutePathBuf>,
) -> Result<AppRunControl> {
// 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<AbsolutePathBuf>,
managed_worktree: Option<(
codex_worktree::WorktreeManager,
codex_worktree::ManagedWorktree,
)>,
) -> Result<AppRunControl> { ) -> Result<AppRunControl> {
if self.reconnect.offline || self.windows_sandbox_blocks_thread_switch() { 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); return Ok(AppRunControl::Continue);
} }
let Some((config, remote_cwd)) = self let Some((config, remote_cwd)) = self
.agents_overview_session_config(tui, app_server, cwd) .agents_overview_session_config(tui, app_server, cwd)
.await .await
else { 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); return Ok(AppRunControl::Continue);
}; };
let selected_profile = self.chat_widget.thread_id().and_then(|thread_id| { let selected_profile = self.chat_widget.thread_id().and_then(|thread_id| {
@@ -33,7 +94,7 @@ impl App {
display_label: active.id, display_label: active.id,
}) })
}); });
let started = match app_server let result = app_server
.start_thread_with_session_start_source( .start_thread_with_session_start_source(
&crate::local_settings::LocalSettings::from(&config), &crate::local_settings::LocalSettings::from(&config),
&config, &config,
@@ -41,11 +102,18 @@ impl App {
remote_cwd.as_deref(), remote_cwd.as_deref(),
selected_profile.as_ref(), selected_profile.as_ref(),
) )
.await .await;
{ let started = match result {
Ok(started) => started, Ok(started) => started,
Err(error) => { 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); return Ok(AppRunControl::Continue);
} }
}; };
@@ -55,17 +123,120 @@ impl App {
.selected_permission_profiles .selected_permission_profiles
.insert(thread_id, selected.profile_id); .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 self.agents_overview
.blank_sessions .blank_sessions
.insert(thread_id, started.clone()); .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 let control = self
.attach_agents_overview_thread(tui, app_server, thread_id, Some((config, started))) .attach_agents_overview_thread(tui, app_server, thread_id, Some((config, started)))
.await?; .await?;
if self.current_displayed_thread_id() != Some(thread_id) { if self.current_displayed_thread_id() != Some(thread_id) {
self.agents_overview.blank_sessions.remove(&thread_id); self.agents_overview.blank_sessions.remove(&thread_id);
let _ = app_server.thread_unsubscribe(thread_id).await; 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) Ok(control)
} }
pub(in crate::app) async fn new_agents_overview_worktree(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
cwd: Option<AbsolutePathBuf>,
) {
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));
});
}
} }
+18 -1
View File
@@ -27,6 +27,8 @@ impl AgentsOverviewView {
)) ))
} else if state.connection_notice.is_some() { } else if state.connection_notice.is_some() {
Some("ctrl+c quit · actions paused until the list is refreshed".into()) 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 { } else {
None None
}; };
@@ -40,9 +42,15 @@ impl AgentsOverviewView {
if width < 24 { if width < 24 {
let hints = [ let hints = [
("new_task", &self.agents_keymap.new_task, "new"), ("new_task", &self.agents_keymap.new_task, "new"),
(
"new_worktree",
&self.agents_keymap.new_worktree,
"new worktree",
),
("search", &self.agents_keymap.search, "search"), ("search", &self.agents_keymap.search, "search"),
] ]
.into_iter() .into_iter()
.filter(|(action, _, _)| *action != "new_worktree" || self.worktrees_enabled)
.filter_map(|(action, bindings, label)| { .filter_map(|(action, bindings, label)| {
self.agents_keymap self.agents_keymap
.primary_hint(action, bindings) .primary_hint(action, bindings)
@@ -65,6 +73,7 @@ impl AgentsOverviewView {
&self.agents_keymap.resume, &self.agents_keymap.resume,
&self.agents_keymap.search, &self.agents_keymap.search,
&self.agents_keymap.new_task, &self.agents_keymap.new_task,
&self.agents_keymap.new_worktree,
&self.agents_keymap.rename, &self.agents_keymap.rename,
&self.agents_keymap.stop, &self.agents_keymap.stop,
&self.agents_keymap.archive, &self.agents_keymap.archive,
@@ -125,6 +134,12 @@ impl AgentsOverviewView {
"new", "new",
true, true,
); );
add_hint(
self.agents_keymap
.primary_hint("new_worktree", &self.agents_keymap.new_worktree),
"new worktree",
self.worktrees_enabled,
);
add_hint( add_hint(
self.agents_keymap self.agents_keymap
.primary_hint("search", &self.agents_keymap.search), .primary_hint("search", &self.agents_keymap.search),
@@ -254,7 +269,9 @@ impl Renderable for AgentsOverviewView {
} }
}); });
let attention = format!("{needs_you} need input"); 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); Line::from(notice.cyan()).render(inset(summary), buf);
} else if self.state().refresh_failed { } else if self.state().refresh_failed {
Line::from("Error loading tasks".red()).render(inset(summary), buf); Line::from("Error loading tasks".red()).render(inset(summary), buf);
@@ -1,6 +1,25 @@
use super::super::agents_overview_view::AgentsOverviewGrouping; use super::super::agents_overview_view::AgentsOverviewGrouping;
use super::*; 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] #[tokio::test]
async fn overview_thread_colors_match_footer_and_respect_color_suppression() { async fn overview_thread_colors_match_footer_and_respect_color_suppression() {
let mut app = make_test_app().await; let mut app = make_test_app().await;
@@ -2630,6 +2649,7 @@ async fn command_center_escape_cancels_editors_and_never_closes_list() {
#[tokio::test] #[tokio::test]
async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone() { async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone() {
let mut app = make_test_app().await; let mut app = make_test_app().await;
app.config.features.enable(Feature::Worktrees).unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx); app.app_event_tx = AppEventSender::new(tx);
let id = ThreadId::new(); let id = ThreadId::new();
@@ -2646,6 +2666,10 @@ async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone(
assert!( assert!(
matches!(rx.try_recv(), Ok(AppEvent::NewAgentsOverviewSession { cwd: Some(cwd) }) if cwd == target.cwd) 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('g').into());
} }
view.handle_key_event(KeyCode::Char('r').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(), rx.try_recv(),
Ok(AppEvent::NewAgentsOverviewSession { cwd: None }) 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());
} }
+12 -1
View File
@@ -134,6 +134,7 @@ impl AgentsOverviewProjectGroup {
pub(super) struct AgentsOverviewViewState { pub(super) struct AgentsOverviewViewState {
pub(super) input: String, pub(super) input: String,
pub(super) key_chord_hint: Option<Vec<(String, String)>>, pub(super) key_chord_hint: Option<Vec<(String, String)>>,
pub(super) creating_worktree: bool,
pub(super) refresh_failed: bool, pub(super) refresh_failed: bool,
pub(super) connection_notice: Option<&'static str>, pub(super) connection_notice: Option<&'static str>,
pub(super) server_version_notice: Option<String>, pub(super) server_version_notice: Option<String>,
@@ -160,6 +161,7 @@ pub(super) struct AgentsOverviewView {
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
keymap: ListKeymap, keymap: ListKeymap,
agents_keymap: AgentsKeymap, agents_keymap: AgentsKeymap,
worktrees_enabled: bool,
} }
impl AgentsOverviewView { impl AgentsOverviewView {
@@ -189,6 +191,7 @@ impl AgentsOverviewView {
app_event_tx, app_event_tx,
keymap: keymap.list, keymap: keymap.list,
agents_keymap: keymap.agents, agents_keymap: keymap.agents,
worktrees_enabled,
}; };
view.state().completion = None; view.state().completion = None;
let visible = view.visible_indices(); let visible = view.visible_indices();
@@ -585,7 +588,7 @@ impl BottomPaneView for AgentsOverviewView {
return; 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) && self.keymap.action_for(key) != Some(ListAction::Cancel)
{ {
match self.keymap.action_for(key) { match self.keymap.action_for(key) {
@@ -615,6 +618,14 @@ impl BottomPaneView for AgentsOverviewView {
}); });
return; 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 self.agents_keymap.rename.is_pressed(key) {
if let Some(row) = self.selected_row() { if let Some(row) = self.selected_row() {
let mut state = self.state(); let mut state = self.state();
+22
View File
@@ -37,6 +37,7 @@ impl App {
| AppEvent::CommitRealtimeTranscriptHistory | AppEvent::CommitRealtimeTranscriptHistory
| AppEvent::ResetTranscriptForThreadSwitch | AppEvent::ResetTranscriptForThreadSwitch
| AppEvent::ManagedWorktreeCreated(_) | AppEvent::ManagedWorktreeCreated(_)
| AppEvent::AgentsOverviewWorktreeCreated(_)
| AppEvent::AppendMessageHistoryEntry { .. } | AppEvent::AppendMessageHistoryEntry { .. }
| AppEvent::BeginInitialHistoryReplayBuffer | AppEvent::BeginInitialHistoryReplayBuffer
| AppEvent::BeginThreadSwitchHistoryReplayBuffer | AppEvent::BeginThreadSwitchHistoryReplayBuffer
@@ -2310,6 +2311,9 @@ impl App {
} }
self.config.approvals_reviewer = policy; self.config.approvals_reviewer = policy;
self.chat_widget.set_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() self.sync_active_thread_permission_settings_to_cached_session()
.await; .await;
if let Err(err) = crate::config_update::write_config_batch( if let Err(err) = crate::config_update::write_config_batch(
@@ -2447,6 +2451,24 @@ impl App {
AppEvent::NewAgentsOverviewSession { cwd } => { AppEvent::NewAgentsOverviewSession { cwd } => {
return Box::pin(self.new_agents_overview_session(tui, app_server, cwd)).await; 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 } => { AppEvent::RenameAgentsOverviewThread { thread_id, name } => {
match app_server.thread_set_name(thread_id, name.clone()).await { match app_server.thread_set_name(thread_id, name.clone()).await {
Ok(()) => { Ok(()) => {
+11
View File
@@ -259,6 +259,17 @@ impl App {
let (tx, rx) = mpsc::unbounded_channel(); let (tx, rx) = mpsc::unbounded_channel();
self.app_event_tx = AppEventSender::new(tx); self.app_event_tx = AppEventSender::new(tx);
*app_event_rx = rx; *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.agent_navigation.picker_refresh = None;
self.last_subagent_backfill_attempt = None; self.last_subagent_backfill_attempt = None;
self.rate_limit_refresh_state.invalidate_recovery(); self.rate_limit_refresh_state.invalidate_recovery();
@@ -23,6 +23,6 @@ expression: "render_bottom_popup(&app.chat_widget,\n48).replace(&test_path_displ
↑↓ navigate o resume enter open n new ↑↓ navigate o resume enter open n new
f search g group: project r rename x stop w new worktree f search g group: project
f5 f8 hide f5 f6 archive f5 f7 delete r rename x stop f5 f8 hide f5 f6 archive
ctrl+c quit f5 f7 delete ctrl+c quit
@@ -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 " " ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename "
" a archive del delete ctrl+c quit " " x stop h hide a archive del delete ctrl+c quit "
@@ -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 ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename
a archive del delete ctrl+c quit x stop h hide a archive del delete ctrl+c quit
@@ -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 ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename
a archive del delete ctrl+c quit x stop h hide a archive del delete ctrl+c quit
@@ -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 ↑↓ navigate o resume enter open n new w new worktree f search g group: model r rename x stop
del delete ctrl+c quit h hide a archive del delete ctrl+c quit
@@ -20,9 +20,9 @@ expression: "render_bottom_popup(&app.chat_widget, 12)"
n new n new
w new
worktree
f search f search
ctrl+c ctrl+c
quit quit
@@ -2,4 +2,4 @@
source: tui/src/app/agents_overview_tests.rs source: tui/src/app/agents_overview_tests.rs
expression: rendered.lines().last().unwrap() expression: rendered.lines().last().unwrap()
--- ---
a archive del delete ctrl+c quit x stop h hide a archive del delete ctrl+c quit
@@ -2,4 +2,4 @@
source: tui/src/app/agents_overview_tests.rs source: tui/src/app/agents_overview_tests.rs
expression: "rendered.lines().find(|line| line.contains(\"open\")).unwrap()" 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
@@ -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
@@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Review parser │ Review parser
│ │
│ │
↑↓ 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
a archive del delete ctrl+c quit x stop h hide a archive del delete ctrl+c quit
@@ -238,6 +238,12 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
.note_turn_finished(&TurnStatus::Completed, before_disconnect); .note_turn_finished(&TurnStatus::Completed, before_disconnect);
} }
app.schedule_recap_check(id, Instant::now()); 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 old_sender = app.app_event_tx.clone();
let connected = reconnect( let connected = reconnect(
app.app_server_target.clone(), 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") app.finish_reconnect(&mut tui, &mut session, &mut events, connected, "2.1.0")
.await?; .await?;
assert!(app.pending_server_profiles.is_empty()); 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.reconnect.offline);
assert!(!app.thread_unavailable(id)); assert!(!app.thread_unavailable(id));
assert_eq!(app.last_subagent_backfill_attempt, None); assert_eq!(app.last_subagent_backfill_attempt, None);
@@ -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 super::*;
use crate::app::agents_overview::AGENTS_OVERVIEW_VIEW_ID; 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??; proxy.await??;
Ok(()) 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(), "<worktree>")
);
failed_server.shutdown().await?;
failed_proxy.await??;
}
}
Ok(())
}
@@ -236,7 +236,9 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<
assert!( assert!(
!std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( !std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!(
event, event,
AppEvent::NewAgentsOverviewSession { .. } | AppEvent::OpenResumePicker AppEvent::NewAgentsOverviewSession { .. }
| AppEvent::NewAgentsOverviewWorktree { .. }
| AppEvent::OpenResumePicker
)) ))
); );
assert_snapshot!( assert_snapshot!(
@@ -0,0 +1,10 @@
---
source: tui/src/app/tests/background_task_defaults_tests.rs
expression: "rendered.replace(&unused.root.display().to_string(), \"<worktree>\")"
---
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 <worktree>; remove it with `git worktree remove <checkout-path>` from the source repository if it is no longer needed.
› 1. Return to command center
Press enter to confirm or esc to go back
@@ -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 ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename x stop
del delete ctrl+c quit h hide a archive del delete ctrl+c quit
@@ -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 ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename x stop
del delete ctrl+c quit h hide a archive del delete ctrl+c quit
+5
View File
@@ -287,6 +287,11 @@ pub(crate) enum AppEvent {
NewAgentsOverviewSession { NewAgentsOverviewSession {
cwd: Option<AbsolutePathBuf>, cwd: Option<AbsolutePathBuf>,
}, },
/// Create an empty session in a worktree from the selected project's default branch.
NewAgentsOverviewWorktree {
cwd: Option<AbsolutePathBuf>,
},
AgentsOverviewWorktreeCreated(Result<crate::app::PendingWorktree, String>),
/// Rename a task directly from the shared dashboard. /// Rename a task directly from the shared dashboard.
RenameAgentsOverviewThread { RenameAgentsOverviewThread {
thread_id: ThreadId, thread_id: ThreadId,
+12
View File
@@ -409,6 +409,7 @@ pub(crate) struct AgentsKeymap {
pub(crate) resume: Vec<KeyBinding>, pub(crate) resume: Vec<KeyBinding>,
pub(crate) search: Vec<KeyBinding>, pub(crate) search: Vec<KeyBinding>,
pub(crate) new_task: Vec<KeyBinding>, pub(crate) new_task: Vec<KeyBinding>,
pub(crate) new_worktree: Vec<KeyBinding>,
pub(crate) rename: Vec<KeyBinding>, pub(crate) rename: Vec<KeyBinding>,
pub(crate) stop: Vec<KeyBinding>, pub(crate) stop: Vec<KeyBinding>,
pub(crate) archive: Vec<KeyBinding>, pub(crate) archive: Vec<KeyBinding>,
@@ -1278,6 +1279,7 @@ impl RuntimeKeymap {
resume: resolve_local!(keymap, defaults, agents, resume), resume: resolve_local!(keymap, defaults, agents, resume),
search: resolve_local!(keymap, defaults, agents, search), search: resolve_local!(keymap, defaults, agents, search),
new_task: resolve_local!(keymap, defaults, agents, new_task), 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), rename: resolve_local!(keymap, defaults, agents, rename),
stop: resolve_local!(keymap, defaults, agents, stop), stop: resolve_local!(keymap, defaults, agents, stop),
archive: resolve_local!(keymap, defaults, agents, archive), archive: resolve_local!(keymap, defaults, agents, archive),
@@ -1292,6 +1294,11 @@ impl RuntimeKeymap {
(keymap.agents.resume.as_ref(), &mut agents.resume, "o"), (keymap.agents.resume.as_ref(), &mut agents.resume, "o"),
(keymap.agents.search.as_ref(), &mut agents.search, "f"), (keymap.agents.search.as_ref(), &mut agents.search, "f"),
(keymap.agents.new_task.as_ref(), &mut agents.new_task, "n"), (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.rename.as_ref(), &mut agents.rename, "r"),
(keymap.agents.stop.as_ref(), &mut agents.stop, "x"), (keymap.agents.stop.as_ref(), &mut agents.stop, "x"),
(keymap.agents.archive.as_ref(), &mut agents.archive, "a"), (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.resume.as_ref(), &mut agents.resume),
(keymap.agents.search.as_ref(), &mut agents.search), (keymap.agents.search.as_ref(), &mut agents.search),
(keymap.agents.new_task.as_ref(), &mut agents.new_task), (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.rename.as_ref(), &mut agents.rename),
(keymap.agents.stop.as_ref(), &mut agents.stop), (keymap.agents.stop.as_ref(), &mut agents.stop),
(keymap.agents.archive.as_ref(), &mut agents.archive), (keymap.agents.archive.as_ref(), &mut agents.archive),
@@ -1813,6 +1824,7 @@ impl RuntimeKeymap {
resume: default_bindings![plain(KeyCode::Char('o'))], resume: default_bindings![plain(KeyCode::Char('o'))],
search: default_bindings![plain(KeyCode::Char('f'))], search: default_bindings![plain(KeyCode::Char('f'))],
new_task: default_bindings![plain(KeyCode::Char('n'))], new_task: default_bindings![plain(KeyCode::Char('n'))],
new_worktree: default_bindings![plain(KeyCode::Char('w'))],
rename: default_bindings![plain(KeyCode::Char('r'))], rename: default_bindings![plain(KeyCode::Char('r'))],
stop: default_bindings![plain(KeyCode::Char('x'))], stop: default_bindings![plain(KeyCode::Char('x'))],
archive: default_bindings![plain(KeyCode::Char('a'))], archive: default_bindings![plain(KeyCode::Char('a'))],
+1
View File
@@ -396,6 +396,7 @@ define_runtime_action_bindings! {
resume, resume,
search, search,
new_task, new_task,
new_worktree,
rename, rename,
stop, stop,
archive, archive,
+7 -1
View File
@@ -40,7 +40,12 @@ See the Codex keymap documentation for supported actions and examples."
#[test] #[test]
fn new_agents_defaults_preserve_existing_custom_bindings() { 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 [ for (context, existing, suffix) in [
("agents", "stop", ""), ("agents", "stop", ""),
("list", "move_down", ""), ("list", "move_down", ""),
@@ -65,6 +70,7 @@ fn new_agents_defaults_preserve_existing_custom_bindings() {
"archive" => &runtime.agents.archive, "archive" => &runtime.agents.archive,
"delete" => &runtime.agents.delete, "delete" => &runtime.agents.delete,
"hide" => &runtime.agents.hide, "hide" => &runtime.agents.hide,
"new_worktree" => &runtime.agents.new_worktree,
_ => unreachable!(), _ => unreachable!(),
}; };
assert!( assert!(
+2
View File
@@ -216,6 +216,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("agents", "Agents", "resume", "Open the session resume picker."), action("agents", "Agents", "resume", "Open the session resume picker."),
action("agents", "Agents", "search", "Search the available agent tasks."), 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_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", "rename", "Rename the selected task."),
action("agents", "Agents", "stop", "Stop the selected running task."), action("agents", "Agents", "stop", "Stop the selected running task."),
action("agents", "Agents", "archive", "Archive the selected task and its child agents."), 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", "resume") => Some(&mut keymap.agents.resume),
("agents", "search") => Some(&mut keymap.agents.search), ("agents", "search") => Some(&mut keymap.agents.search),
("agents", "new_task") => Some(&mut keymap.agents.new_task), ("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", "rename") => Some(&mut keymap.agents.rename),
("agents", "stop") => Some(&mut keymap.agents.stop), ("agents", "stop") => Some(&mut keymap.agents.stop),
("agents", "archive") => Some(&mut keymap.agents.archive), ("agents", "archive") => Some(&mut keymap.agents.archive),
@@ -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 ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename
a archive del delete ctrl+c quit x stop h hide a archive del delete ctrl+c quit
@@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Repair authentication │ Repair authentication
│ │
│ │
↑↓ 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
a archive del delete ctrl+c quit x stop h hide a archive del delete ctrl+c quit
@@ -22,7 +22,7 @@ expression: rendered
↑↓ navigate o resume enter open n new ↑↓ navigate o resume enter open n new
f search g group: project r rename x stop w new worktree f search g group: project
h hide a archive del delete ctrl+c quit r rename x stop h hide a archive del delete
ctrl+c quit
@@ -24,5 +24,5 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ Prompt │ Prompt
│ Investigate parser │ Investigate parser
│ … │ …
↑↓ 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
a archive del delete ctrl+c quit x stop h hide a archive del delete ctrl+c quit
@@ -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 ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename
a archive del delete ctrl+c quit x stop h hide a archive del delete ctrl+c quit
@@ -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
@@ -5,6 +5,7 @@ expression: agents
Resume | o | Agents resume Resume Open the session resume picker. o Default Resume | o | Agents resume Resume Open the session resume picker. o Default
Search | f | Agents search Search Search the available agent tasks. f 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 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 Rename | r | Agents rename Rename Rename the selected task. r Default
Stop | x | Agents stop Stop Stop the selected running task. x 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 Archive | a | Agents archive Archive Archive the selected task and its child agents. a Default
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. 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 [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. 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 [All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
@@ -2,7 +2,7 @@
source: tui/src/keymap_setup.rs source: tui/src/keymap_setup.rs
expression: snapshot expression: snapshot
--- ---
tab: All (145 selectable) tab: All (146 selectable)
tab: Common (20 selectable) tab: Common (20 selectable)
tab: Customized (0) (0 selectable) tab: Customized (0) (0 selectable)
tab: Unbound (5) (5 selectable) tab: Unbound (5) (5 selectable)
@@ -11,7 +11,7 @@ tab: Composer (5 selectable)
tab: Editor (17 selectable) tab: Editor (17 selectable)
tab: Vim (69 selectable) tab: Vim (69 selectable)
tab: Navigation (20 selectable) tab: Navigation (20 selectable)
tab: Agents (9 selectable) tab: Agents (10 selectable)
tab: Approval (8 selectable) tab: Approval (8 selectable)
tab: Debug (1 selectable) tab: Debug (1 selectable)
Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. 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 [All] Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap Keymap
All configurable shortcuts. 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 [All] Common Customized (0) Unbound (5) App Composer Editor Vim
Navigation Agents Approval Debug Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. 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 [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. 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 [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
+45
View File
@@ -94,6 +94,51 @@ pub(crate) fn git_path_from_bytes(bytes: &[u8]) -> Result<PathBuf> {
} }
} }
/// 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<String> {
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::<Vec<_>>();
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 { fn base_git_command(cwd: &Path) -> Command {
let mut command = Command::new("git"); let mut command = Command::new("git");
// Git wrappers export repository-local state to their children. Select this // Git wrappers export repository-local state to their children. Select this
+30
View File
@@ -166,3 +166,33 @@ fn write_marker_script(path: &Path, marker: &Path) -> Result<()> {
fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
Ok(()) 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(())
}
+1
View File
@@ -20,6 +20,7 @@ use std::path::Component;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
pub use git::default_worktree_base;
pub use settings::DEFAULT_WORKTREE_KEEP_COUNT; pub use settings::DEFAULT_WORKTREE_KEEP_COUNT;
pub use settings::WorktreeSettings; pub use settings::WorktreeSettings;
+16 -6
View File
@@ -4,6 +4,7 @@ use codex_worktree::CreateWorktree;
use codex_worktree::ManagedWorktree; use codex_worktree::ManagedWorktree;
use codex_worktree::WorktreeManager; use codex_worktree::WorktreeManager;
use codex_worktree::WorktreeSettings; use codex_worktree::WorktreeSettings;
use codex_worktree::default_worktree_base;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use serde_json::Value; use serde_json::Value;
use serde_json::json; use serde_json::json;
@@ -434,12 +435,10 @@ fn creation_ignores_inherited_git_environment() {
let settings = let settings =
WorktreeSettings::from_desktop_config(&root.join("codex-home"), /*desktop*/ None) WorktreeSettings::from_desktop_config(&root.join("codex-home"), /*desktop*/ None)
.expect("fixture operation succeeds"); .expect("fixture operation succeeds");
let worktree = create_worktree( let base = default_worktree_base(&source_cwd).expect("resolve source default");
&WorktreeManager::new(settings), assert_eq!(base, "refs/remotes/origin/source-default");
&source_cwd, let worktree = create_worktree(&WorktreeManager::new(settings), &source_cwd, Some(&base))
/*base*/ None, .expect("fixture operation succeeds");
)
.expect("fixture operation succeeds");
assert_eq!(worktree.source_root, source); assert_eq!(worktree.source_root, source);
assert_eq!(worktree.source_cwd, source_cwd); assert_eq!(worktree.source_cwd, source_cwd);
assert_eq!( assert_eq!(
@@ -462,6 +461,17 @@ fn creation_ignores_inherited_git_environment() {
.expect("fixture operation succeeds"); .expect("fixture operation succeeds");
run_git(&fixture.repository, &["add", "."]); run_git(&fixture.repository, &["add", "."]);
commit(&fixture.repository, "checkout filter attributes"); 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")); let mut child = Command::new(std::env::current_exe().expect("current test executable"));
child child
.args(["--exact", "creation_ignores_inherited_git_environment", "--nocapture"]) .args(["--exact", "creation_ignores_inherited_git_environment", "--nocapture"])