From 250390cb76801e1c2c04a4666be7fcd3a0e97d3f Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 6 May 2026 21:07:37 -0300 Subject: [PATCH] feat(tui): add worktree slash command --- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/event_dispatch.rs | 35 + codex-rs/tui/src/app/worktree.rs | 352 +++++++++ codex-rs/tui/src/app_event.rs | 35 + codex-rs/tui/src/chatwidget.rs | 15 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 11 + codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 4 + ...__tests__worktree_dirty_policy_prompt.snap | 14 + ...ui__worktree__tests__worktree_loading.snap | 13 + ...tui__worktree__tests__worktree_picker.snap | 14 + ...__tests__worktree_remove_confirmation.snap | 12 + ...__worktree__tests__worktree_switching.snap | 13 + codex-rs/tui/src/worktree.rs | 708 ++++++++++++++++++ codex-rs/worktree/src/lib.rs | 1 + 15 files changed, 1229 insertions(+) create mode 100644 codex-rs/tui/src/app/worktree.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_loading.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_remove_confirmation.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_switching.snap create mode 100644 codex-rs/tui/src/worktree.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 176663058a..b50541f9c2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index bfe8dc4b24..892b3734e1 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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(); } diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs new file mode 100644 index 0000000000..11d689b0d6 --- /dev/null +++ b/codex-rs/tui/src/app/worktree.rs @@ -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, 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, + dirty_policy: Option, + ) { + 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::>(); + 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> { + 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()); + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4ee405f495..34a31e3d89 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, String>, + }, + + /// Create or reuse a managed worktree and switch the TUI into it. + CreateWorktreeAndSwitch { + branch: String, + base_ref: Option, + dirty_policy: Option, + }, + + /// 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 diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0dbf0d235a..aac43984de 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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() } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 6d1278ea2d..f5f6f39271 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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 diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 26ec4ec6e2..48de5bbfc4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d5e923f0e3..9e3e19a7f3 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap new file mode 100644 index 0000000000..548fda3de9 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_loading.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_loading.snap new file mode 100644 index 0000000000..ace4a3f023 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_loading.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap new file mode 100644 index 0000000000..ef0b294c81 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_remove_confirmation.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_remove_confirmation.snap new file mode 100644 index 0000000000..59892b7a45 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_remove_confirmation.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_switching.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_switching.snap new file mode 100644 index 0000000000..11ad971539 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_switching.snap @@ -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 diff --git a/codex-rs/tui/src/worktree.rs b/codex-rs/tui/src/worktree.rs new file mode 100644 index 0000000000..a911766953 --- /dev/null +++ b/codex-rs/tui/src/worktree.rs @@ -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 |switch |path |remove ]"; +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, + dirty_policy: Option, + }, + 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 { + 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) -> Result { + let Some(branch) = parts.next() else { + return Err("Usage: /worktree new [--base ] [--dirty ]".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 --base ".to_string()); + }; + base_ref = Some(value.to_string()); + } + "--dirty" => { + let Some(value) = parts.next() else { + return Err("Usage: /worktree new --dirty ".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, +) -> Result { + let Some(target) = parts.next() else { + return Err( + "Usage: /worktree remove [--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, + command: &str, +) -> Result { + let Some(target) = parts.next() else { + return Err(format!("Usage: /worktree {command} ")); + }; + if parts.next().is_some() { + return Err(format!("Usage: /worktree {command} ")); + } + Ok(target.to_string()) +} + +fn parse_dirty_policy(value: &str) -> Result { + 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 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, 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::>(); + + 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, +) -> 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::>(); + 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::(); + 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 = (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") + } +} diff --git a/codex-rs/worktree/src/lib.rs b/codex-rs/worktree/src/lib.rs index a5adb60bc7..3ca1a70f41 100644 --- a/codex-rs/worktree/src/lib.rs +++ b/codex-rs/worktree/src/lib.rs @@ -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;