mirror of
https://github.com/openai/codex.git
synced 2026-11-12 14:41:42 +00:00
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:
1 parent
44b9011611
commit
6f39a47bb3
49 files changed
+732
-55
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
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}"));
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(()) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+3
-3
@@ -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
|
||||||
+2
-2
@@ -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 "
|
||||||
+2
-2
@@ -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
|
||||||
+2
-2
@@ -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
|
||||||
+2
-2
@@ -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
|
||||||
+2
-2
@@ -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
|
||||||
+1
-1
@@ -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
|
||||||
+1
-1
@@ -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
|
||||||
+28
@@ -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
|
||||||
+2
-2
@@ -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!(
|
||||||
|
|||||||
+10
@@ -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
|
||||||
+2
-2
@@ -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
|
||||||
+2
-2
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'))],
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ define_runtime_action_bindings! {
|
|||||||
resume,
|
resume,
|
||||||
search,
|
search,
|
||||||
new_task,
|
new_task,
|
||||||
|
new_worktree,
|
||||||
rename,
|
rename,
|
||||||
stop,
|
stop,
|
||||||
archive,
|
archive,
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
+2
-2
@@ -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
|
||||||
+3
-3
@@ -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
|
||||||
+2
-2
@@ -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
|
||||||
+2
-2
@@ -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
|
||||||
+1
-1
@@ -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
|
||||||
+1
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
-2
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +435,9 @@ 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);
|
||||||
@@ -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"])
|
||||||
|
|||||||
Reference in new issue
Block a user