feat(tui): add worktree slash command

This commit is contained in:
Felipe Coury
2026-05-06 21:07:37 -03:00
parent 5a6efcf183
commit 250390cb76
15 changed files with 1229 additions and 0 deletions

View File

@@ -201,6 +201,7 @@ mod thread_events;
mod thread_goal_actions;
mod thread_routing;
mod thread_session_state;
mod worktree;
use self::agent_navigation::AgentNavigationDirection;
use self::agent_navigation::AgentNavigationState;

View File

@@ -187,6 +187,41 @@ impl App {
tui.frame_requester().schedule_frame();
}
AppEvent::OpenWorktreePicker => {
self.open_worktree_picker(tui);
tui.frame_requester().schedule_frame();
}
AppEvent::WorktreesLoaded { cwd, result } => {
self.on_worktrees_loaded(cwd, result);
tui.frame_requester().schedule_frame();
}
AppEvent::CreateWorktreeAndSwitch {
branch,
base_ref,
dirty_policy,
} => {
self.create_worktree_and_switch(tui, app_server, branch, base_ref, dirty_policy)
.await;
tui.frame_requester().schedule_frame();
}
AppEvent::SwitchToWorktree { target } => {
self.switch_to_worktree_target(tui, app_server, target)
.await;
tui.frame_requester().schedule_frame();
}
AppEvent::ShowWorktreePath { target } => {
self.show_worktree_path(target);
tui.frame_requester().schedule_frame();
}
AppEvent::RemoveWorktree {
target,
force,
delete_branch,
confirmed,
} => {
self.remove_worktree(target, force, delete_branch, confirmed);
tui.frame_requester().schedule_frame();
}
AppEvent::BeginInitialHistoryReplayBuffer => {
self.begin_initial_history_replay_buffer();
}

View File

@@ -0,0 +1,352 @@
//! App-layer handlers for the worktree TUI flow.
use codex_worktree::DirtyPolicy;
use codex_worktree::WorktreeInfo;
use codex_worktree::WorktreeListQuery;
use codex_worktree::WorktreeRemoveRequest;
use codex_worktree::WorktreeRequest;
use codex_worktree::WorktreeSource;
use std::path::PathBuf;
use super::*;
impl App {
pub(super) fn open_worktree_picker(&mut self, tui: &mut tui::Tui) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
self.chat_widget
.show_selection_view(crate::worktree::loading_params(
tui.frame_requester(),
self.config.animations,
));
self.fetch_worktrees_for_picker();
}
pub(super) fn on_worktrees_loaded(
&mut self,
cwd: PathBuf,
result: Result<Vec<WorktreeInfo>, String>,
) {
if cwd.as_path() != self.config.cwd.as_path() {
return;
}
let params = match result {
Ok(entries) if entries.is_empty() => crate::worktree::empty_params(),
Ok(entries) => crate::worktree::picker_params(entries, self.config.cwd.as_path()),
Err(err) => crate::worktree::error_params(err),
};
self.replace_worktree_view(params);
}
pub(super) async fn create_worktree_and_switch(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
branch: String,
base_ref: Option<String>,
dirty_policy: Option<DirtyPolicy>,
) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
let dirty_policy = match dirty_policy {
Some(policy) => policy,
None => match codex_worktree::dirty_state(self.config.cwd.as_path()) {
Ok(state) if state.is_dirty() => {
let params = crate::worktree::dirty_policy_prompt_params(branch, base_ref);
self.chat_widget.show_selection_view(params);
return;
}
Ok(_) => DirtyPolicy::Fail,
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to inspect source checkout: {err}"));
return;
}
},
};
let resolution = match codex_worktree::ensure_worktree(WorktreeRequest {
codex_home: self.config.codex_home.to_path_buf(),
source_cwd: self.config.cwd.to_path_buf(),
branch,
base_ref,
dirty_policy,
}) {
Ok(resolution) => resolution,
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to create worktree: {err}"));
return;
}
};
let warnings = resolution
.warnings
.iter()
.map(|warning| warning.message.clone())
.collect::<Vec<_>>();
let target = resolution
.info
.branch
.clone()
.unwrap_or_else(|| resolution.info.name.clone());
self.show_worktree_switching_view(tui, target).await;
self.switch_to_worktree_info(tui, app_server, resolution.info)
.await;
for warning in warnings {
self.chat_widget.add_info_message(warning, /*hint*/ None);
}
}
pub(super) async fn switch_to_worktree_target(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
target: String,
) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
self.show_worktree_switching_view(tui, target.clone()).await;
let entries = match self.list_current_repo_worktrees() {
Ok(entries) => entries,
Err(err) => {
self.show_worktree_error("Failed to list worktrees.".to_string(), err.to_string());
return;
}
};
let info = match crate::worktree::find_worktree(&entries, &target) {
Ok(info) => info.clone(),
Err(err) => {
self.show_worktree_error("Failed to switch worktree.".to_string(), err);
return;
}
};
self.switch_to_worktree_info(tui, app_server, info).await;
}
pub(super) fn show_worktree_path(&mut self, target: String) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
match self.list_current_repo_worktrees() {
Ok(entries) => match crate::worktree::find_worktree(&entries, &target) {
Ok(info) => {
self.chat_widget.add_info_message(
info.workspace_cwd.display().to_string(),
/*hint*/ None,
);
}
Err(err) => self.chat_widget.add_error_message(err),
},
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to list worktrees: {err}"));
}
}
}
pub(super) fn remove_worktree(
&mut self,
target: String,
force: bool,
delete_branch: bool,
confirmed: bool,
) {
if self.remote_app_server_url.is_some() {
self.chat_widget.add_error_message(
"/worktree is not supported for remote sessions yet.".to_string(),
);
return;
}
let entries = match self.list_current_repo_worktrees() {
Ok(entries) => entries,
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to list worktrees: {err}"));
return;
}
};
let info = match crate::worktree::find_worktree(&entries, &target) {
Ok(info) => info,
Err(err) => {
self.chat_widget.add_error_message(err);
return;
}
};
if info.source != WorktreeSource::Cli {
let source = crate::worktree::source_label(info.source);
self.chat_widget.add_error_message(format!(
"Refusing to remove {source} worktree '{target}'. Only Codex CLI-managed worktrees can be removed."
));
return;
}
if !confirmed {
let params = crate::worktree::remove_confirmation_params(target, force, delete_branch);
self.chat_widget.show_selection_view(params);
return;
}
match codex_worktree::remove_worktree(WorktreeRemoveRequest {
codex_home: self.config.codex_home.to_path_buf(),
source_cwd: Some(self.config.cwd.to_path_buf()),
name_or_path: target.clone(),
force,
delete_branch,
}) {
Ok(result) => {
let mut message = format!("Removed worktree {}", result.removed_path.display());
if let Some(branch) = result.deleted_branch {
message.push_str(&format!(" and deleted branch {branch}"));
}
self.chat_widget.add_info_message(message, /*hint*/ None);
}
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to remove worktree: {err}"));
}
}
}
fn list_current_repo_worktrees(&self) -> anyhow::Result<Vec<WorktreeInfo>> {
codex_worktree::list_worktrees(WorktreeListQuery {
codex_home: self.config.codex_home.to_path_buf(),
source_cwd: Some(self.config.cwd.to_path_buf()),
include_all_repos: false,
})
}
fn fetch_worktrees_for_picker(&mut self) {
let query = WorktreeListQuery {
codex_home: self.config.codex_home.to_path_buf(),
source_cwd: Some(self.config.cwd.to_path_buf()),
include_all_repos: false,
};
let cwd = self.config.cwd.to_path_buf();
let app_event_tx = self.app_event_tx.clone();
tokio::task::spawn_blocking(move || {
let result = codex_worktree::list_worktrees(query).map_err(|err| err.to_string());
app_event_tx.send(AppEvent::WorktreesLoaded { cwd, result });
});
}
async fn switch_to_worktree_info(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
info: WorktreeInfo,
) {
let mut config = match self
.rebuild_config_for_cwd(info.workspace_cwd.clone())
.await
{
Ok(config) => config,
Err(err) => {
self.show_worktree_error(
"Failed to rebuild configuration for worktree.".to_string(),
err.to_string(),
);
return;
}
};
self.apply_runtime_policy_overrides(&mut config);
if let Some(thread_id) = self.chat_widget.thread_id() {
match app_server.fork_thread(config.clone(), thread_id).await {
Ok(forked) => {
self.shutdown_current_thread(app_server).await;
self.install_worktree_config(tui, config);
if let Err(err) = self
.replace_chat_widget_with_app_server_thread(
tui, app_server, forked, /*initial_user_message*/ None,
)
.await
{
self.show_worktree_error(
"Failed to attach to worktree thread.".to_string(),
err.to_string(),
);
}
}
Err(err) => {
self.show_worktree_error(
"Failed to fork current session into worktree.".to_string(),
err.to_string(),
);
}
}
} else {
self.shutdown_current_thread(app_server).await;
self.install_worktree_config(tui, config.clone());
match app_server.start_thread(&config).await {
Ok(started) => {
if let Err(err) = self
.replace_chat_widget_with_app_server_thread(
tui, app_server, started, /*initial_user_message*/ None,
)
.await
{
self.show_worktree_error(
"Failed to attach to worktree thread.".to_string(),
err.to_string(),
);
}
}
Err(err) => {
self.show_worktree_error(
"Failed to start session in worktree.".to_string(),
err.to_string(),
);
}
}
}
tui.frame_requester().schedule_frame();
}
async fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) {
self.chat_widget
.show_selection_view(crate::worktree::switching_params(
target,
tui.frame_requester(),
self.config.animations,
));
tui.frame_requester().schedule_frame();
tokio::task::yield_now().await;
}
fn replace_worktree_view(&mut self, params: crate::bottom_pane::SelectionViewParams) -> bool {
self.chat_widget
.replace_selection_view_if_active(crate::worktree::WORKTREE_SELECTION_VIEW_ID, params)
}
fn show_worktree_error(&mut self, summary: String, error: String) {
let params = crate::worktree::error_with_summary_params(summary.clone(), error.clone());
if !self.replace_worktree_view(params) {
self.chat_widget
.add_error_message(format!("{summary} {error}"));
}
}
fn install_worktree_config(&mut self, tui: &mut tui::Tui, config: Config) {
self.config = config;
tui.set_notification_settings(
self.config.tui_notifications.method,
self.config.tui_notifications.condition,
);
self.file_search
.update_search_dir(self.config.cwd.to_path_buf());
}
}

View File

@@ -31,6 +31,7 @@ use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_approval_presets::ApprovalPreset;
use codex_worktree::DirtyPolicy;
use crate::app_command::AppCommand;
use crate::bottom_pane::ApprovalRequest;
@@ -191,6 +192,40 @@ pub(crate) enum AppEvent {
/// Fork the current session into a new thread.
ForkCurrentSession,
/// Open the managed worktree picker.
OpenWorktreePicker,
/// Result of loading worktrees for the managed worktree picker.
WorktreesLoaded {
cwd: PathBuf,
result: Result<Vec<codex_worktree::WorktreeInfo>, String>,
},
/// Create or reuse a managed worktree and switch the TUI into it.
CreateWorktreeAndSwitch {
branch: String,
base_ref: Option<String>,
dirty_policy: Option<DirtyPolicy>,
},
/// Switch the TUI into an existing worktree.
SwitchToWorktree {
target: String,
},
/// Show the filesystem path for an existing worktree.
ShowWorktreePath {
target: String,
},
/// Remove a Codex-managed worktree.
RemoveWorktree {
target: String,
force: bool,
delete_branch: bool,
confirmed: bool,
},
/// Request to exit the application.
///
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the

View File

@@ -5377,6 +5377,21 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn replace_selection_view_if_active(
&mut self,
view_id: &'static str,
params: SelectionViewParams,
) -> bool {
let replaced = self
.bottom_pane
.replace_selection_view_if_active(view_id, params);
if replaced {
self.refresh_plan_mode_nudge();
self.request_redraw();
}
replaced
}
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
self.bottom_pane.no_modal_or_popup_active()
}

View File

@@ -154,6 +154,9 @@ impl ChatWidget {
SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::ForkCurrentSession);
}
SlashCommand::Worktree => {
self.app_event_tx.send(AppEvent::OpenWorktreePicker);
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
if init_target.exists() {
@@ -772,6 +775,13 @@ impl ChatWidget {
self.app_event_tx
.send(AppEvent::ResumeSessionByIdOrName(args));
}
SlashCommand::Worktree if !trimmed.is_empty() => {
if let Err(message) =
crate::worktree::dispatch_worktree_slash_args(trimmed, &self.app_event_tx)
{
self.add_error_message(message);
}
}
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
@@ -918,6 +928,7 @@ impl ChatWidget {
| SlashCommand::Clear
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Worktree
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Review

View File

@@ -179,6 +179,7 @@ mod tui;
mod ui_consts;
pub(crate) mod update_action;
pub use update_action::UpdateAction;
mod worktree;
mod worktree_labels;
#[cfg(not(debug_assertions))]
pub use update_action::get_update_action;

View File

@@ -33,6 +33,7 @@ pub enum SlashCommand {
New,
Resume,
Fork,
Worktree,
Init,
Compact,
Plan,
@@ -87,6 +88,7 @@ impl SlashCommand {
SlashCommand::Resume => "resume a saved chat",
SlashCommand::Clear => "clear the terminal and start a new chat",
SlashCommand::Fork => "fork the current chat",
SlashCommand::Worktree => "manage worktrees",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Copy => "copy last response as markdown",
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
@@ -158,6 +160,7 @@ impl SlashCommand {
| SlashCommand::Raw
| SlashCommand::Side
| SlashCommand::Resume
| SlashCommand::Worktree
| SlashCommand::SandboxReadRoot
)
}
@@ -181,6 +184,7 @@ impl SlashCommand {
SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Worktree
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Model

View File

@@ -0,0 +1,14 @@
---
source: tui/src/worktree.rs
expression: "render_selection(dirty_policy_prompt_params(\"fcoury/demo\".to_string(), None),\n82)"
---
Source checkout has uncommitted changes
Choose what to carry into the new worktree.
1. Fail Cancel creation and leave the source checkout unchanged.
2. Ignore Create from the requested base without copying local changes.
3. Copy tracked Copy staged and unstaged tracked changes.
4. Copy all Copy tracked changes and untracked files.
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,13 @@
---
source: tui/src/worktree.rs
expression: "render_selection(loading_params(FrameRequester::test_dummy(), false), 92)"
---
Worktrees
• Loading worktrees...
This can take a moment when Codex is checking app, CLI, and Git worktrees.
Loading worktrees... This can take a moment when Codex is checking app, CLI, and Git
worktrees.
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,14 @@
---
source: tui/src/worktree.rs
expression: "render_selection(params, 86)"
---
Worktrees
Select a worktree to fork this chat into that workspace.
Search worktrees
fcoury/demo (current) Fork this chat into /repo/codex.fcoury-demo
codex clean · app · /repo/codex.codex
main dirty · git · /repo/codex.main
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,12 @@
---
source: tui/src/worktree.rs
expression: "render_selection(remove_confirmation_params(\"fcoury/demo\".to_string(), false,\nfalse), 80)"
---
Remove worktree fcoury/demo?
Only Codex-managed worktrees can be removed.
1. Remove Remove the selected worktree.
2. Cancel Keep the worktree.
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,13 @@
---
source: tui/src/worktree.rs
expression: "render_selection(switching_params(\"fcoury/demo\".to_string(),\nFrameRequester::test_dummy(), false), 92)"
---
Worktrees
• Switching to fcoury/demo...
Codex is rebuilding configuration and starting the chat in that workspace.
Preparing worktree session... Codex is rebuilding configuration and starting the
chat in that workspace.
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,708 @@
use std::path::Path;
use std::time::Duration;
use std::time::Instant;
use codex_worktree::DirtyPolicy;
use codex_worktree::WorktreeInfo;
use codex_worktree::WorktreeSource;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ColumnWidthMode;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionRowDisplay;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::tui::FrameRequester;
const WORKTREE_USAGE: &str =
"Usage: /worktree [list|new <branch>|switch <branch>|path <branch>|remove <branch>]";
pub(crate) const WORKTREE_SELECTION_VIEW_ID: &str = "worktree-selection";
const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
struct WorktreeLoadingHeader {
started_at: Instant,
frame_requester: FrameRequester,
animations_enabled: bool,
status: String,
note: String,
}
impl WorktreeLoadingHeader {
fn new(
frame_requester: FrameRequester,
animations_enabled: bool,
status: String,
note: String,
) -> Self {
Self {
started_at: Instant::now(),
frame_requester,
animations_enabled,
status,
note,
}
}
}
impl Renderable for WorktreeLoadingHeader {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let motion_mode = MotionMode::from_animations_enabled(self.animations_enabled);
if self.animations_enabled {
self.frame_requester
.schedule_frame_in(LOADING_ANIMATION_INTERVAL);
}
let mut loading_spans = Vec::new();
if let Some(indicator) = activity_indicator(
Some(self.started_at),
motion_mode,
ReducedMotionIndicator::StaticBullet,
) {
loading_spans.push(indicator);
loading_spans.push(" ".into());
}
loading_spans.push(self.status.clone().dim());
Paragraph::new(vec![
Line::from("Worktrees".bold()),
Line::from(loading_spans),
Line::from(self.note.clone().dim()),
])
.render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
3
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WorktreeSlashAction {
OpenPicker,
Create {
branch: String,
base_ref: Option<String>,
dirty_policy: Option<DirtyPolicy>,
},
Switch {
target: String,
},
ShowPath {
target: String,
},
Remove {
target: String,
force: bool,
delete_branch: bool,
},
}
impl WorktreeSlashAction {
pub(crate) fn dispatch(self, tx: &AppEventSender) {
match self {
WorktreeSlashAction::OpenPicker => tx.send(AppEvent::OpenWorktreePicker),
WorktreeSlashAction::Create {
branch,
base_ref,
dirty_policy,
} => tx.send(AppEvent::CreateWorktreeAndSwitch {
branch,
base_ref,
dirty_policy,
}),
WorktreeSlashAction::Switch { target } => {
tx.send(AppEvent::SwitchToWorktree { target });
}
WorktreeSlashAction::ShowPath { target } => {
tx.send(AppEvent::ShowWorktreePath { target });
}
WorktreeSlashAction::Remove {
target,
force,
delete_branch,
} => tx.send(AppEvent::RemoveWorktree {
target,
force,
delete_branch,
confirmed: force,
}),
}
}
}
pub(crate) fn parse_worktree_slash_args(args: &str) -> Result<WorktreeSlashAction, String> {
let mut parts = args.split_whitespace();
let Some(command) = parts.next() else {
return Ok(WorktreeSlashAction::OpenPicker);
};
match command {
"list" => Ok(WorktreeSlashAction::OpenPicker),
"new" => parse_new(parts),
"switch" | "move" => {
let target = required_target(parts, command)?;
Ok(WorktreeSlashAction::Switch { target })
}
"path" => {
let target = required_target(parts, command)?;
Ok(WorktreeSlashAction::ShowPath { target })
}
"remove" => parse_remove(parts),
_ => Err(WORKTREE_USAGE.to_string()),
}
}
fn parse_new<'a>(mut parts: impl Iterator<Item = &'a str>) -> Result<WorktreeSlashAction, String> {
let Some(branch) = parts.next() else {
return Err("Usage: /worktree new <branch> [--base <ref>] [--dirty <mode>]".to_string());
};
let mut base_ref = None;
let mut dirty_policy = None;
while let Some(flag) = parts.next() {
match flag {
"--base" => {
let Some(value) = parts.next() else {
return Err("Usage: /worktree new <branch> --base <ref>".to_string());
};
base_ref = Some(value.to_string());
}
"--dirty" => {
let Some(value) = parts.next() else {
return Err("Usage: /worktree new <branch> --dirty <mode>".to_string());
};
dirty_policy = Some(parse_dirty_policy(value)?);
}
_ => return Err(format!("Unknown /worktree new option '{flag}'.")),
}
}
Ok(WorktreeSlashAction::Create {
branch: branch.to_string(),
base_ref,
dirty_policy,
})
}
fn parse_remove<'a>(
mut parts: impl Iterator<Item = &'a str>,
) -> Result<WorktreeSlashAction, String> {
let Some(target) = parts.next() else {
return Err(
"Usage: /worktree remove <branch-or-name> [--force] [--delete-branch]".to_string(),
);
};
let mut force = false;
let mut delete_branch = false;
for flag in parts {
match flag {
"--force" => force = true,
"--delete-branch" => delete_branch = true,
_ => return Err(format!("Unknown /worktree remove option '{flag}'.")),
}
}
Ok(WorktreeSlashAction::Remove {
target: target.to_string(),
force,
delete_branch,
})
}
fn required_target<'a>(
mut parts: impl Iterator<Item = &'a str>,
command: &str,
) -> Result<String, String> {
let Some(target) = parts.next() else {
return Err(format!("Usage: /worktree {command} <branch-or-name>"));
};
if parts.next().is_some() {
return Err(format!("Usage: /worktree {command} <branch-or-name>"));
}
Ok(target.to_string())
}
fn parse_dirty_policy(value: &str) -> Result<DirtyPolicy, String> {
match value {
"fail" => Ok(DirtyPolicy::Fail),
"ignore" => Ok(DirtyPolicy::Ignore),
"copy-tracked" => Ok(DirtyPolicy::CopyTracked),
"copy-all" => Ok(DirtyPolicy::CopyAll),
_ => Err("Dirty mode must be one of: fail, ignore, copy-tracked, copy-all.".to_string()),
}
}
pub(crate) fn dispatch_worktree_slash_args(args: &str, tx: &AppEventSender) -> Result<(), String> {
parse_worktree_slash_args(args)?.dispatch(tx);
Ok(())
}
pub(crate) fn loading_params(
frame_requester: FrameRequester,
animations_enabled: bool,
) -> SelectionViewParams {
let status = "Loading worktrees...".to_string();
let note =
"This can take a moment when Codex is checking app, CLI, and Git worktrees.".to_string();
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(WorktreeLoadingHeader::new(
frame_requester,
animations_enabled,
status.clone(),
note.clone(),
)),
footer_hint: Some(standard_popup_hint_line()),
items: vec![SelectionItem {
name: status,
description: Some(note),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
pub(crate) fn switching_params(
target: String,
frame_requester: FrameRequester,
animations_enabled: bool,
) -> SelectionViewParams {
let status = format!("Switching to {target}...");
let note =
"Codex is rebuilding configuration and starting the chat in that workspace.".to_string();
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(WorktreeLoadingHeader::new(
frame_requester,
animations_enabled,
status,
note.clone(),
)),
footer_hint: Some(standard_popup_hint_line()),
items: vec![SelectionItem {
name: "Preparing worktree session...".to_string(),
description: Some(note),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
pub(crate) fn empty_params() -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Worktrees".bold()));
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items: vec![SelectionItem {
name: "No worktrees found for this repository.".to_string(),
description: Some("Use /worktree new <branch> to create one.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
pub(crate) fn error_params(error: String) -> SelectionViewParams {
error_with_summary_params("Failed to list worktrees.".to_string(), error)
}
pub(crate) fn error_with_summary_params(summary: String, error: String) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Worktrees".bold()));
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items: vec![SelectionItem {
name: summary,
description: Some(error),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
pub(crate) fn picker_params(entries: Vec<WorktreeInfo>, current_cwd: &Path) -> SelectionViewParams {
let items = entries
.into_iter()
.map(|entry| {
let target = entry.branch.clone().unwrap_or_else(|| entry.name.clone());
let source = source_label(entry.source);
let status = if entry.dirty.is_dirty() {
"dirty"
} else {
"clean"
};
let description = format!("{status} · {source} · {}", entry.workspace_cwd.display());
let search_value = Some(format!(
"{} {} {} {}",
target,
entry.name,
source,
entry.workspace_cwd.display()
));
SelectionItem {
name: target.clone(),
description: Some(description),
selected_description: Some(format!(
"Fork this chat into {}",
entry.workspace_cwd.display()
)),
is_current: paths_match(current_cwd, &entry.workspace_cwd),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::SwitchToWorktree {
target: target.clone(),
});
})],
dismiss_on_select: true,
search_value,
..Default::default()
}
})
.collect::<Vec<_>>();
let mut header = ColumnRenderable::new();
header.push(Line::from("Worktrees".bold()));
header.push(Line::from(
"Select a worktree to fork this chat into that workspace.".dim(),
));
SelectionViewParams {
view_id: Some(WORKTREE_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Search worktrees".to_string()),
col_width_mode: ColumnWidthMode::AutoAllRows,
row_display: SelectionRowDisplay::SingleLine,
..Default::default()
}
}
pub(crate) fn dirty_policy_prompt_params(
branch: String,
base_ref: Option<String>,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Source checkout has uncommitted changes".bold()));
header.push(Line::from(
"Choose what to carry into the new worktree.".dim(),
));
let item = |name: &str, description: &str, dirty_policy: DirtyPolicy| SelectionItem {
name: name.to_string(),
description: Some(description.to_string()),
actions: vec![Box::new({
let branch = branch.clone();
let base_ref = base_ref.clone();
move |tx| {
tx.send(AppEvent::CreateWorktreeAndSwitch {
branch: branch.clone(),
base_ref: base_ref.clone(),
dirty_policy: Some(dirty_policy),
});
}
})],
dismiss_on_select: true,
..Default::default()
};
SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items: vec![
item(
"Fail",
"Cancel creation and leave the source checkout unchanged.",
DirtyPolicy::Fail,
),
item(
"Ignore",
"Create from the requested base without copying local changes.",
DirtyPolicy::Ignore,
),
item(
"Copy tracked",
"Copy staged and unstaged tracked changes.",
DirtyPolicy::CopyTracked,
),
item(
"Copy all",
"Copy tracked changes and untracked files.",
DirtyPolicy::CopyAll,
),
],
..Default::default()
}
}
pub(crate) fn remove_confirmation_params(
target: String,
force: bool,
delete_branch: bool,
) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from(format!("Remove worktree {target}?").bold()));
header.push(Line::from(
"Only Codex-managed worktrees can be removed.".dim(),
));
SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items: vec![
SelectionItem {
name: "Remove".to_string(),
description: Some("Remove the selected worktree.".to_string()),
actions: vec![Box::new({
move |tx| {
tx.send(AppEvent::RemoveWorktree {
target: target.clone(),
force,
delete_branch,
confirmed: true,
});
}
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Cancel".to_string(),
description: Some("Keep the worktree.".to_string()),
dismiss_on_select: true,
..Default::default()
},
],
..Default::default()
}
}
pub(crate) fn find_worktree<'a>(
entries: &'a [WorktreeInfo],
target: &str,
) -> Result<&'a WorktreeInfo, String> {
let matches = entries
.iter()
.filter(|entry| {
entry.branch.as_deref() == Some(target) || entry.name == target || entry.slug == target
})
.collect::<Vec<_>>();
match matches.as_slice() {
[entry] => Ok(entry),
[] => Err(format!("No worktree found matching '{target}'.")),
_ => Err(format!(
"Multiple worktrees match '{target}'; use a more specific name."
)),
}
}
pub(crate) fn source_label(source: WorktreeSource) -> &'static str {
match source {
WorktreeSource::Cli => "cli",
WorktreeSource::App => "app",
WorktreeSource::Legacy => "legacy",
WorktreeSource::Git => "git",
}
}
fn paths_match(a: &Path, b: &Path) -> bool {
let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
let b = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
a == b
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ListSelectionView;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::Renderable;
use crate::tui::FrameRequester;
use codex_worktree::DirtyState;
use codex_worktree::WorktreeLocation;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
#[test]
fn parse_new_with_flags() {
assert_eq!(
parse_worktree_slash_args("new fcoury/demo --base origin/main --dirty copy-tracked"),
Ok(WorktreeSlashAction::Create {
branch: "fcoury/demo".to_string(),
base_ref: Some("origin/main".to_string()),
dirty_policy: Some(DirtyPolicy::CopyTracked),
})
);
}
#[test]
fn parse_switch_aliases_move() {
assert_eq!(
parse_worktree_slash_args("move fcoury/demo"),
Ok(WorktreeSlashAction::Switch {
target: "fcoury/demo".to_string(),
})
);
}
#[test]
fn parse_remove_with_flags() {
assert_eq!(
parse_worktree_slash_args("remove fcoury/demo --force --delete-branch"),
Ok(WorktreeSlashAction::Remove {
target: "fcoury/demo".to_string(),
force: true,
delete_branch: true,
})
);
}
#[test]
fn worktree_picker_snapshot() {
let params = picker_params(
vec![
sample_info("fcoury/demo", WorktreeSource::Cli, /*dirty*/ false),
sample_info("codex", WorktreeSource::App, /*dirty*/ false),
sample_info("main", WorktreeSource::Git, /*dirty*/ true),
],
Path::new("/repo/codex.fcoury-demo"),
);
insta::assert_snapshot!("worktree_picker", render_selection(params, /*width*/ 86));
}
#[test]
fn worktree_loading_snapshot() {
insta::assert_snapshot!(
"worktree_loading",
render_selection(
loading_params(
FrameRequester::test_dummy(),
/*animations_enabled*/ false
),
/*width*/ 92
)
);
}
#[test]
fn worktree_switching_snapshot() {
insta::assert_snapshot!(
"worktree_switching",
render_selection(
switching_params(
"fcoury/demo".to_string(),
FrameRequester::test_dummy(),
/*animations_enabled*/ false
),
/*width*/ 92
)
);
}
#[test]
fn worktree_dirty_policy_prompt_snapshot() {
insta::assert_snapshot!(
"worktree_dirty_policy_prompt",
render_selection(
dirty_policy_prompt_params("fcoury/demo".to_string(), /*base_ref*/ None),
/*width*/ 82
)
);
}
#[test]
fn worktree_remove_confirmation_snapshot() {
insta::assert_snapshot!(
"worktree_remove_confirmation",
render_selection(
remove_confirmation_params(
"fcoury/demo".to_string(),
/*force*/ false,
/*delete_branch*/ false
),
/*width*/ 80
)
);
}
fn sample_info(branch: &str, source: WorktreeSource, dirty: bool) -> WorktreeInfo {
let path = PathBuf::from(format!("/repo/codex.{}", branch.replace('/', "-")));
WorktreeInfo {
id: "repo-id".to_string(),
name: branch.to_string(),
slug: branch.replace('/', "-"),
source,
location: match source {
WorktreeSource::Cli => WorktreeLocation::Sibling,
WorktreeSource::App | WorktreeSource::Legacy => WorktreeLocation::CodexHome,
WorktreeSource::Git => WorktreeLocation::External,
},
repo_name: "codex".to_string(),
repo_root: path.clone(),
common_git_dir: PathBuf::from("/repo/codex/.git"),
worktree_git_root: path.clone(),
workspace_cwd: path,
original_relative_cwd: PathBuf::new(),
branch: Some(branch.to_string()),
head: Some("abcdef".to_string()),
owner_thread_id: None,
metadata_path: PathBuf::from("/repo/codex/.git/codex-worktree.json"),
dirty: DirtyState {
has_staged_changes: false,
has_unstaged_changes: dirty,
has_untracked_files: false,
},
}
}
fn render_selection(params: SelectionViewParams, width: u16) -> String {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = ListSelectionView::new(params, tx, RuntimeKeymap::defaults().list);
let height = view.desired_height(width);
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line.trim_end().to_string()
})
.collect();
lines.join("\n")
}
}

View File

@@ -11,6 +11,7 @@ use serde::Serialize;
pub use dirty::DirtyPolicy;
pub use dirty::DirtyState;
pub use dirty::dirty_state;
pub use manager::ensure_worktree;
pub use manager::list_worktrees;
pub use manager::remove_worktree;