diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 8ceaa3957c..ab21f792fe 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -97,6 +97,26 @@ impl App { AppEvent::ManagedWorktreeCreated(created) => { self.finish_managed_worktree(tui, app_server, *created).await; } + AppEvent::BrowseManagedWorktrees => { + if let Some(request) = self.chat_widget.request_managed_worktrees() { + crate::worktree_browser::fetch( + request, + self.config.codex_home.to_path_buf(), + self.app_event_tx.clone(), + ); + } + } + AppEvent::ManagedWorktreesLoaded { request, result } => { + self.chat_widget.on_managed_worktrees_loaded(request, result); + } + AppEvent::ManagedWorktreeAction { request, action } => { + if let Some(event) = self.chat_widget.managed_worktree_action(&request, action) { + self.app_event_tx.send(event); + } + } + AppEvent::ShowManagedWorktreeActions { request, entry } => { + self.chat_widget.show_managed_worktree_actions(request, entry); + } AppEvent::ChangeWorkingDirectory { thread_id, requested_cwd, diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index e9ebf9a357..aff15e577d 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -2954,6 +2954,7 @@ model_reasoning_effort = "low" codex_worktree::WorktreeSettings::for_cli(&home, /*desktop*/ None) .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, ); + let mut browser_entries = Vec::new(); requests.lock().expect("request recorder lock").clear(); app.handle_event( &mut tui, @@ -2972,6 +2973,10 @@ model_reasoning_effort = "low" .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))? .pop() .expect("unused checkout"); + browser_entries.push(crate::worktree_browser::Entry { + cwd: unused.cwd.clone(), + owner: None, + }); assert_eq!( manager .owner(&unused.root) @@ -3035,6 +3040,16 @@ terminal_visualization_instructions = true complete_managed_worktree_creation(&mut app, &mut tui, &mut server, &mut events).await?; assert_eq!(app.chat_widget.thread_id(), Some(original)); assert!(recorded_params(&requests, "thread/fork").is_empty()); + let feature_mismatch_checkout = manager + .list(&source) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))? + .into_iter() + .find(|checkout| checkout.cwd != unused.cwd) + .expect("feature mismatch leaves an unused checkout"); + browser_entries.push(crate::worktree_browser::Entry { + cwd: feature_mismatch_checkout.cwd, + owner: None, + }); while events.try_recv().is_ok() {} app.handle_event( &mut tui, @@ -3062,6 +3077,13 @@ terminal_visualization_instructions = true .1 .root .clone(); + let stale_cwd = created + .result + .as_ref() + .expect("created checkout") + .1 + .cwd + .clone(); app.primary_thread_id = Some(ThreadId::new()); app.handle_event( &mut tui, @@ -3089,6 +3111,10 @@ terminal_visualization_instructions = true .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, None ); + browser_entries.push(crate::worktree_browser::Entry { + cwd: stale_cwd, + owner: None, + }); for mode in [ManagedWorktreeMode::New, ManagedWorktreeMode::Fork] { requests.lock().expect("request recorder lock").clear(); let previous = app.chat_widget.thread_id(); @@ -3117,6 +3143,10 @@ terminal_visualization_instructions = true .iter() .find(|checkout| checkout.cwd.canonicalize().ok().as_ref() == Some(&cwd)) .expect("managed checkout"); + browser_entries.push(crate::worktree_browser::Entry { + cwd: checkout.cwd.clone(), + owner: Some(replacement), + }); assert!(checkout.root.starts_with(home.join("worktrees"))); assert!(!project_pool.exists()); assert_eq!( @@ -3173,6 +3203,39 @@ terminal_visualization_instructions = true .await?; assert_eq!(attached.cwd.as_path().canonicalize()?, cwd); } + browser_entries.sort_by(|left, right| left.cwd.cmp(&right.cwd)); + let nested = source.join("browser-only"); + fs::create_dir(&nested)?; + assert_eq!( + crate::worktree_browser::list(home.clone(), nested) + .await + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, + browser_entries + ); + let unowned = manager + .create(&codex_worktree::CreateWorktree { + source_cwd: source.clone(), + base: None, + }) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + browser_entries.push(crate::worktree_browser::Entry { + cwd: unowned.cwd, + owner: None, + }); + browser_entries.sort_by(|left, right| left.cwd.cmp(&right.cwd)); + for owner in [None, Some("not-a-thread-uuid")] { + if let Some(owner) = owner { + manager + .bind_thread(&unowned.root, owner) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + } + assert_eq!( + crate::worktree_browser::list(home.clone(), source.clone()) + .await + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, + browser_entries + ); + } app.start_fresh_session_with_summary_hint( &mut tui, &mut server, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 819b8ace8f..f08692a148 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -419,6 +419,20 @@ pub(crate) enum AppEvent { /// Continue a checkout transition after synchronous Git work finishes off-loop. ManagedWorktreeCreated(Box), + BrowseManagedWorktrees, + ManagedWorktreesLoaded { + request: crate::worktree_browser::Request, + result: Result, String>, + }, + ManagedWorktreeAction { + request: crate::worktree_browser::Request, + action: crate::worktree_browser::Action, + }, + ShowManagedWorktreeActions { + request: crate::worktree_browser::Request, + entry: crate::worktree_browser::Entry, + }, + /// Change the working directory of the originating idle primary thread. ChangeWorkingDirectory { thread_id: ThreadId, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 852eaa1ff6..13aaa4adf8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -575,6 +575,7 @@ pub(crate) struct ChatWidget { model_catalog: Arc, model_popup_request_id: Option, permission_popup_request_id: Option, + worktree_popup_request_id: Option, permission_profiles_menu_opened: bool, model_popup_model_ids: Vec, session_telemetry: SessionTelemetry, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index e2ad6d54c6..6a2d5dee83 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -127,6 +127,7 @@ impl ChatWidget { model_catalog, model_popup_request_id: None, permission_popup_request_id: None, + worktree_popup_request_id: None, permission_profiles_menu_opened: false, model_popup_model_ids: Vec::new(), session_telemetry, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_actions.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_actions.snap new file mode 100644 index 0000000000..59abf1a3ed --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_actions.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/worktree_picker_tests.rs +assertion_line: 114 +expression: "render_bottom_popup(&chat, 80)" +--- + Worktree + /repo/worktree + +› 1. Resume owner thread + 2. Copy working directory + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_list.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_list.snap new file mode 100644 index 0000000000..61d7777386 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_list.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/worktree_picker_tests.rs +assertion_line: 100 +expression: "render_bottom_popup(&chat, 80)" +--- + Managed worktrees + Select a worktree to resume its owner or copy its working directory + + +› /repo/worktree Owner: 00000000-0000-0000-0000-000000000001 + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_loading.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_loading.snap new file mode 100644 index 0000000000..e6381ff916 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktree_browser_loading.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests/worktree_picker_tests.rs +assertion_line: 90 +expression: "render_bottom_popup(&chat, 80)" +--- + Managed worktrees + +› Loading worktrees… + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap index a91fc16b17..ddf97f9c3f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_conversation_choices.snap @@ -1,12 +1,15 @@ --- -source: tui/src/chatwidget/tests/slash_commands.rs +source: tui/src/chatwidget/tests/worktree_picker_tests.rs +assertion_line: 71 expression: popup --- - Create a new worktree + Worktrees › 1. Continue current conversation Preserve this conversation in the new checkout 2. Start new conversation Open a fresh conversation in the new checkout + 3. Browse worktrees Resume an owner thread or copy a working + directory Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs index d6fc917381..ebfa30af68 100644 --- a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs @@ -89,3 +89,85 @@ async fn slash_worktree_offers_current_or_new_conversation() { assert!(popup.contains("Start new conversation"), "popup: {popup}"); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); } + +#[tokio::test] +async fn worktree_browser_actions_and_stale_results() { + use crate::worktree_browser::Entry; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let checkout = tempdir().unwrap(); + std::fs::create_dir(checkout.path().join(".git")).unwrap(); + std::fs::write(checkout.path().join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + chat.config.cwd = AbsolutePathBuf::from_absolute_path(checkout.path()).unwrap(); + chat.set_feature_enabled(Feature::Worktrees, /*enabled*/ true); + let request = chat.request_managed_worktrees().unwrap(); + assert_chatwidget_snapshot!( + "worktree_browser_loading", + render_bottom_popup(&chat, /*width*/ 80) + ); + let owner = ThreadId::from_string("00000000-0000-0000-0000-000000000001").unwrap(); + let entry = Entry { + cwd: PathBuf::from("/repo/worktree"), + owner: Some(owner), + }; + chat.on_managed_worktrees_loaded(request, Ok(vec![entry.clone()])); + assert_chatwidget_snapshot!( + "worktree_browser_list", + render_bottom_popup(&chat, /*width*/ 80) + ); + for character in "worktree".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(character))); + } + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let AppEvent::ShowManagedWorktreeActions { + request, + entry: selected, + } = rx.try_recv().unwrap() + else { + panic!("worktree action"); + }; + assert_eq!(selected, entry); + chat.show_managed_worktree_actions(request.clone(), selected); + assert_chatwidget_snapshot!( + "worktree_browser_actions", + render_bottom_popup(&chat, /*width*/ 80) + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let AppEvent::ManagedWorktreeAction { + request: selected_request, + action, + } = rx.try_recv().unwrap() + else { + panic!("resume action"); + }; + assert_matches!(chat.managed_worktree_action(&selected_request, action.clone()), Some(AppEvent::ResumeSessionByIdOrName(id)) if id == owner.to_string()); + chat.set_local_worktree_operations(/*enabled*/ false); + assert!( + chat.managed_worktree_action(&selected_request, action) + .is_none() + ); + chat.set_local_worktree_operations(/*enabled*/ true); + chat.show_managed_worktree_actions( + request, + Entry { + owner: None, + ..entry + }, + ); + assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Resume owner")); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let AppEvent::ManagedWorktreeAction { request, action } = rx.try_recv().unwrap() else { + panic!("copy action"); + }; + assert_matches!(chat.managed_worktree_action(&request, action), Some(AppEvent::CopySelection { text, .. }) if &*text == "/repo/worktree"); + let first = chat.request_managed_worktrees().unwrap(); + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.on_managed_worktrees_loaded(first, Ok(Vec::new())); + assert!(!chat.bottom_pane.has_active_view()); + let stale = chat.request_managed_worktrees().unwrap(); + let current = chat.request_managed_worktrees().unwrap(); + chat.on_managed_worktrees_loaded(stale, Err("stale result".to_string())); + assert_eq!(chat.worktree_popup_request_id, Some(current.id)); + chat.config.cwd = AbsolutePathBuf::from_absolute_path(checkout.path().join("other")).unwrap(); + chat.on_managed_worktrees_loaded(current, Ok(Vec::new())); + assert!(!chat.bottom_pane.has_active_view()); +} diff --git a/codex-rs/tui/src/chatwidget/worktree_picker.rs b/codex-rs/tui/src/chatwidget/worktree_picker.rs index 31359aa8eb..d9a8d3b067 100644 --- a/codex-rs/tui/src/chatwidget/worktree_picker.rs +++ b/codex-rs/tui/src/chatwidget/worktree_picker.rs @@ -2,6 +2,11 @@ use super::*; use crate::app_event::ManagedWorktreeMode; +use crate::worktree_browser::Action; +use crate::worktree_browser::Entry; +use crate::worktree_browser::Request; + +const BROWSER_VIEW_ID: &str = "managed-worktrees"; impl ChatWidget { pub(super) fn managed_worktree_available(&self) -> bool { @@ -86,7 +91,7 @@ impl ChatWidget { } self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Create a new worktree".to_string()), + title: Some("Worktrees".to_string()), footer_hint: Some(standard_popup_hint_line()), items: vec![ SelectionItem { @@ -113,9 +118,175 @@ impl ChatWidget { dismiss_on_select: true, ..Default::default() }, + SelectionItem { + name: "Browse worktrees".to_string(), + description: Some( + "Resume an owner thread or copy a working directory".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::BrowseManagedWorktrees))], + dismiss_on_select: true, + ..Default::default() + }, ], ..Default::default() }); self.request_redraw(); } + + pub(crate) fn request_managed_worktrees(&mut self) -> Option { + if !self.managed_worktree_available() { + return None; + } + let request = Request { + id: uuid::Uuid::new_v4(), + cwd: self.config.cwd.to_path_buf(), + thread_id: self.thread_id, + }; + self.bottom_pane.dismiss_view_by_id(BROWSER_VIEW_ID); + self.worktree_popup_request_id = Some(request.id); + self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(BROWSER_VIEW_ID), + title: Some("Managed worktrees".to_string()), + items: vec![SelectionItem { + name: "Loading worktrees…".to_string(), + is_disabled: true, + ..Default::default() + }], + footer_hint: Some(standard_popup_hint_line()), + ..Default::default() + }); + Some(request) + } + + fn worktree_request_is_current(&self, request: &Request) -> bool { + self.worktree_popup_request_id == Some(request.id) + && request.cwd == self.config.cwd.as_path() + && request.thread_id == self.thread_id + && self.managed_worktree_available() + } + + pub(crate) fn on_managed_worktrees_loaded( + &mut self, + request: Request, + result: Result, String>, + ) { + if self.worktree_popup_request_id != Some(request.id) { + return; + } + if !self.worktree_request_is_current(&request) + || !self.bottom_pane.dismiss_active_view_if_id(BROWSER_VIEW_ID) + { + self.worktree_popup_request_id = None; + self.bottom_pane.dismiss_view_by_id(BROWSER_VIEW_ID); + return; + } + let entries = match result { + Ok(entries) => entries, + Err(error) => { + self.worktree_popup_request_id = None; + self.add_error_message(format!("Cannot list managed worktrees: {error}")); + return; + } + }; + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Managed worktrees".to_string()), + subtitle: Some( + if entries.is_empty() { + "No worktrees in this repository's configured pool" + } else { + "Select a worktree to resume its owner or copy its working directory" + } + .to_string(), + ), + is_searchable: true, + items: entries + .into_iter() + .map(|entry| { + let request = request.clone(); + SelectionItem { + name: entry.cwd.display().to_string(), + search_value: Some(entry.cwd.display().to_string()), + description: Some(entry.owner.map_or_else( + || "No owner metadata".to_string(), + |owner| format!("Owner: {owner}"), + )), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ShowManagedWorktreeActions { + request: request.clone(), + entry: entry.clone(), + }) + })], + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(), + footer_hint: Some(standard_popup_hint_line()), + ..Default::default() + }); + } + + pub(crate) fn managed_worktree_action( + &self, + request: &Request, + action: Action, + ) -> Option { + if !self.worktree_request_is_current(request) { + return None; + } + Some(match action { + Action::Resume(owner) => AppEvent::ResumeSessionByIdOrName(owner.to_string()), + Action::Copy(cwd) => AppEvent::CopySelection { + text: cwd.to_str()?.into(), + label: "Worktree working directory".to_string(), + format: crate::clipboard_copy::CopyFormat::PlainText, + }, + }) + } + + pub(crate) fn show_managed_worktree_actions(&mut self, request: Request, entry: Entry) { + if !self.worktree_request_is_current(&request) { + return; + } + let mut items = Vec::new(); + if let Some(owner) = entry.owner { + let request = request.clone(); + items.push(SelectionItem { + name: "Resume owner thread".to_string(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ManagedWorktreeAction { + request: request.clone(), + action: Action::Resume(owner), + }) + })], + dismiss_on_select: true, + ..Default::default() + }); + } + let cwd = entry.cwd.clone(); + items.push(SelectionItem { + name: "Copy working directory".to_string(), + is_disabled: entry.cwd.to_str().is_none(), + disabled_reason: entry + .cwd + .to_str() + .is_none() + .then(|| "Path is not valid UTF-8".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ManagedWorktreeAction { + request: request.clone(), + action: Action::Copy(cwd.clone()), + }) + })], + dismiss_on_select: true, + ..Default::default() + }); + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Worktree".to_string()), + subtitle: Some(entry.cwd.display().to_string()), + items, + footer_hint: Some(standard_popup_hint_line()), + ..Default::default() + }); + } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 89a3746a13..5514c66907 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -127,6 +127,7 @@ mod daybreak; mod experimental_features; mod permission_discovery; mod pets; +mod worktree_browser; pub use custom_terminal::Terminal; mod assistant_directives; mod auto_review_denials; diff --git a/codex-rs/tui/src/worktree_browser.rs b/codex-rs/tui/src/worktree_browser.rs new file mode 100644 index 0000000000..723870af4d --- /dev/null +++ b/codex-rs/tui/src/worktree_browser.rs @@ -0,0 +1,64 @@ +//! Read-only discovery for the local worktree browser; ownership is not activity or exclusion. + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use codex_protocol::ThreadId; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub(crate) struct Request { + pub id: uuid::Uuid, + pub cwd: PathBuf, + pub thread_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Entry { + pub cwd: PathBuf, + pub owner: Option, +} + +#[derive(Clone, Debug)] +pub(crate) enum Action { + Resume(ThreadId), + Copy(PathBuf), +} + +pub(crate) fn fetch(request: Request, codex_home: PathBuf, tx: AppEventSender) { + tokio::spawn(async move { + let result = list(codex_home, request.cwd.clone()) + .await + .map_err(|error| error.to_string()); + tx.send(AppEvent::ManagedWorktreesLoaded { request, result }); + }); +} + +pub(crate) async fn list(codex_home: PathBuf, cwd: PathBuf) -> anyhow::Result> { + let host = crate::legacy_core::config::load_config_toml_with_layer_stack( + &codex_home, + /*cwd*/ None, + Vec::new(), + codex_config::ConfigLoadOptions::default(), + ) + .await?; + let settings = + codex_worktree::WorktreeSettings::for_cli(&codex_home, host.config_toml.desktop.as_ref())?; + // Closing the popup discards its result; an already-running blocking Git call still finishes. + tokio::task::spawn_blocking(move || { + let cwd = codex_git_utils::get_git_repo_root(&cwd).unwrap_or(cwd); + let manager = codex_worktree::WorktreeManager::new(settings); + Ok(manager + .list(&cwd)? + .into_iter() + .map(|checkout| Entry { + owner: manager + .owner(&checkout.root) + .ok() + .flatten() + .and_then(|owner| ThreadId::from_string(&owner).ok()), + cwd: checkout.cwd, + }) + .collect()) + }) + .await? +}