diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index f52f627885..f48b5182b2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -50,6 +50,9 @@ use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; +use crate::terminal_multiplexer::FORK_PLACEMENT_REQUIRES_MULTIPLEXER_MESSAGE; +use crate::terminal_multiplexer::ForkPaneSpawnResult; +use crate::terminal_multiplexer::spawn_fork_in_new_pane; #[cfg(test)] use crate::test_support::PathBufExt; use crate::text_formatting::truncate_text; @@ -139,6 +142,7 @@ use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; +use codex_terminal_detection::terminal_info; use codex_terminal_detection::user_agent; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; @@ -4797,7 +4801,7 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } - AppEvent::ForkCurrentSession => { + AppEvent::ForkCurrentSession { placement } => { self.session_telemetry.counter( "codex.thread.fork", /*inc*/ 1, @@ -4808,11 +4812,46 @@ impl App { self.chat_widget.thread_id(), self.chat_widget.thread_name(), ); - self.chat_widget - .add_plain_history_lines(vec!["/fork".magenta().into()]); + let terminal_info = terminal_info(); + if terminal_info.multiplexer.is_none() { + self.chat_widget + .add_plain_history_lines(vec!["/fork".magenta().into()]); + } if let Some(thread_id) = self.chat_widget.thread_id() { self.refresh_in_memory_config_from_disk_best_effort("forking the thread") .await; + if let Some(multiplexer) = terminal_info.multiplexer.as_ref() { + match spawn_fork_in_new_pane( + multiplexer, + &thread_id, + &self.config, + &self.harness_overrides.additional_writable_roots, + placement, + ) + .await + { + ForkPaneSpawnResult::Spawned => { + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } + ForkPaneSpawnResult::InvalidPlacement(message) => { + self.chat_widget.add_error_message(message); + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } + ForkPaneSpawnResult::Failed(err) => { + self.chat_widget.add_error_message(format!( + "Failed to open a new pane for /fork: {err}" + )); + } + } + } else if placement.is_some() { + self.chat_widget.add_error_message( + FORK_PLACEMENT_REQUIRES_MULTIPLEXER_MESSAGE.to_string(), + ); + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } match app_server.fork_thread(self.config.clone(), thread_id).await { Ok(forked) => { self.shutdown_current_thread(app_server).await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0cb16b9004..61b46b79e2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -108,7 +108,9 @@ pub(crate) enum AppEvent { OpenResumePicker, /// Fork the current session into a new thread. - ForkCurrentSession, + ForkCurrentSession { + placement: Option, + }, /// Request to exit the application. /// @@ -576,6 +578,15 @@ pub(crate) enum AppEvent { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ForkPanePlacement { + Left, + Right, + Up, + Down, + Float, +} + /// The exit strategy requested by the UI layer. /// /// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b082a3b68a..4b38ace58f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -63,6 +63,9 @@ use crate::status::StatusHistoryHandle; use crate::status::format_directory_display; use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; +use crate::terminal_multiplexer::fork_command_usage; +use crate::terminal_multiplexer::fork_pane_options; +use crate::terminal_multiplexer::parse_fork_pane_placement; use crate::terminal_title::SetTerminalTitleResult; use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; @@ -5127,7 +5130,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::OpenResumePicker); } SlashCommand::Fork => { - self.app_event_tx.send(AppEvent::ForkCurrentSession); + self.dispatch_fork_command(terminal_info().multiplexer.as_ref()); } SlashCommand::Init => { let init_target = match self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME) { @@ -5545,6 +5548,25 @@ impl ChatWidget { self.queue_user_message(user_message); } } + SlashCommand::Fork => { + if trimmed.is_empty() { + self.app_event_tx + .send(AppEvent::ForkCurrentSession { placement: None }); + return; + } + + let mut parts = trimmed.split_whitespace(); + let placement = parts.next().and_then(parse_fork_pane_placement); + if placement.is_none() || parts.next().is_some() { + self.add_error_message(fork_command_usage( + terminal_info().multiplexer.as_ref(), + )); + return; + } + + self.app_event_tx + .send(AppEvent::ForkCurrentSession { placement }); + } SlashCommand::Review if !trimmed.is_empty() => { let Some((prepared_args, _prepared_elements)) = self .bottom_pane @@ -5577,6 +5599,15 @@ impl ChatWidget { } } + fn dispatch_fork_command(&mut self, multiplexer: Option<&Multiplexer>) { + if let Some(multiplexer) = multiplexer { + self.open_fork_popup(multiplexer); + } else { + self.app_event_tx + .send(AppEvent::ForkCurrentSession { placement: None }); + } + } + fn show_rename_prompt(&mut self) { let tx = self.app_event_tx.clone(); let has_name = self @@ -7526,6 +7557,36 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } + fn open_fork_popup(&mut self, multiplexer: &Multiplexer) { + let items = fork_pane_options(multiplexer) + .iter() + .map(|option| { + let placement = option.placement; + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::ForkCurrentSession { + placement: Some(placement), + }); + })]; + + SelectionItem { + name: format!("/fork {}", option.name), + description: Some(option.description.to_string()), + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Fork into a new pane".to_string()), + subtitle: Some("Choose where to open the fork.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn open_theme_picker(&mut self) { let codex_home = codex_core::config::find_codex_home().ok(); let terminal_width = self diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__fork_selection_popup_tmux.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__fork_selection_popup_tmux.snap new file mode 100644 index 0000000000..361425ba1e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__fork_selection_popup_tmux.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Fork into a new pane + Choose where to open the fork. + +› 1. /fork right Open the fork in a pane to the right. + 2. /fork left Open the fork in a pane to the left. + 3. /fork up Open the fork in a pane above. + 4. /fork down Open the fork in a pane below. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__fork_selection_popup_zellij.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__fork_selection_popup_zellij.snap new file mode 100644 index 0000000000..7f06d4046c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__fork_selection_popup_zellij.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Fork into a new pane + Choose where to open the fork. + +› 1. /fork float Open the fork in a floating pane. + 2. /fork right Open the fork in a pane to the right. + 3. /fork down Open the fork in a pane below. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index af548b0b04..df2e5ae790 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -217,6 +217,7 @@ pub(super) async fn make_chatwidget_manual( skills_initial_state: None, last_unified_wait: None, unified_exec_wait_streak: None, + last_watchdog_owner_activity_signal_at: None, turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), task_complete_pending: false, unified_exec_processes: Vec::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..77a65b7f58 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,4 +1,5 @@ use super::*; +use codex_terminal_detection::Multiplexer; use pretty_assertions::assert_eq; #[tokio::test] @@ -435,9 +436,42 @@ async fn slash_resume_opens_picker() { async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.dispatch_command(SlashCommand::Fork); + chat.dispatch_fork_command(/*multiplexer*/ None); - assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession)); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::ForkCurrentSession { placement: None }) + ); +} + +#[tokio::test] +async fn slash_fork_opens_tmux_popup() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_fork_command(Some(&Multiplexer::Tmux { version: None })); + + assert!( + rx.try_recv().is_err(), + "expected /fork in tmux to open a popup instead of dispatching immediately" + ); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("fork_selection_popup_tmux", popup); +} + +#[tokio::test] +async fn slash_fork_opens_zellij_popup() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_fork_command(Some(&Multiplexer::Zellij {})); + + assert!( + rx.try_recv().is_err(), + "expected /fork in zellij to open a popup instead of dispatching immediately" + ); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("fork_selection_popup_zellij", popup); } #[tokio::test] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2fd9ebd12f..0940a7fa2d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -142,6 +142,7 @@ mod status; mod status_indicator_widget; mod streaming; mod style; +mod terminal_multiplexer; mod terminal_palette; mod terminal_title; mod text_formatting; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb9..d0550b54e7 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -132,6 +132,7 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Plan | SlashCommand::Fast + | SlashCommand::Fork | SlashCommand::SandboxReadRoot ) } diff --git a/codex-rs/tui/src/snapshots/codex_tui__terminal_multiplexer__tests__fork_command_usage_default.snap b/codex-rs/tui/src/snapshots/codex_tui__terminal_multiplexer__tests__fork_command_usage_default.snap new file mode 100644 index 0000000000..18e96200eb --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__terminal_multiplexer__tests__fork_command_usage_default.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/terminal_multiplexer.rs +assertion_line: 367 +expression: fork_command_usage(None) +--- +Usage: /fork diff --git a/codex-rs/tui/src/snapshots/codex_tui__terminal_multiplexer__tests__fork_command_usage_tmux.snap b/codex-rs/tui/src/snapshots/codex_tui__terminal_multiplexer__tests__fork_command_usage_tmux.snap new file mode 100644 index 0000000000..2f4c02c3b4 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__terminal_multiplexer__tests__fork_command_usage_tmux.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/terminal_multiplexer.rs +expression: "fork_command_usage(Some(&Multiplexer::Tmux { version: None }))" +--- +Usage: /fork [right|left|up|down] diff --git a/codex-rs/tui/src/snapshots/codex_tui__terminal_multiplexer__tests__fork_command_usage_zellij.snap b/codex-rs/tui/src/snapshots/codex_tui__terminal_multiplexer__tests__fork_command_usage_zellij.snap new file mode 100644 index 0000000000..ed31fb9b09 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__terminal_multiplexer__tests__fork_command_usage_zellij.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/terminal_multiplexer.rs +expression: "fork_command_usage(Some(&Multiplexer::Zellij {}))" +--- +Usage: /fork [float|right|down] diff --git a/codex-rs/tui/src/terminal_multiplexer.rs b/codex-rs/tui/src/terminal_multiplexer.rs new file mode 100644 index 0000000000..5acdb57363 --- /dev/null +++ b/codex-rs/tui/src/terminal_multiplexer.rs @@ -0,0 +1,540 @@ +use crate::app_event::ForkPanePlacement; +use codex_core::config::Config; +use codex_protocol::ThreadId; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_terminal_detection::Multiplexer; +use shlex::try_join; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Stdio; + +pub(crate) struct MultiplexerSpawnConfig { + pub(crate) program: PathBuf, + pub(crate) args: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ForkPaneSpawnResult { + Spawned, + InvalidPlacement(String), + Failed(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ForkPaneOption { + pub(crate) placement: ForkPanePlacement, + pub(crate) name: &'static str, + pub(crate) description: &'static str, +} + +const TMUX_FORK_PANE_OPTIONS: &[ForkPaneOption] = &[ + ForkPaneOption { + placement: ForkPanePlacement::Right, + name: "right", + description: "Open the fork in a pane to the right.", + }, + ForkPaneOption { + placement: ForkPanePlacement::Left, + name: "left", + description: "Open the fork in a pane to the left.", + }, + ForkPaneOption { + placement: ForkPanePlacement::Up, + name: "up", + description: "Open the fork in a pane above.", + }, + ForkPaneOption { + placement: ForkPanePlacement::Down, + name: "down", + description: "Open the fork in a pane below.", + }, +]; + +const ZELLIJ_FORK_PANE_OPTIONS: &[ForkPaneOption] = &[ + ForkPaneOption { + placement: ForkPanePlacement::Float, + name: "float", + description: "Open the fork in a floating pane.", + }, + ForkPaneOption { + placement: ForkPanePlacement::Right, + name: "right", + description: "Open the fork in a pane to the right.", + }, + ForkPaneOption { + placement: ForkPanePlacement::Down, + name: "down", + description: "Open the fork in a pane below.", + }, +]; + +pub(crate) fn fork_pane_options(multiplexer: &Multiplexer) -> &'static [ForkPaneOption] { + match multiplexer { + Multiplexer::Zellij {} => ZELLIJ_FORK_PANE_OPTIONS, + Multiplexer::Tmux { .. } => TMUX_FORK_PANE_OPTIONS, + } +} + +pub(crate) fn parse_fork_pane_placement(arg: &str) -> Option { + match arg.to_ascii_lowercase().as_str() { + "left" => Some(ForkPanePlacement::Left), + "right" => Some(ForkPanePlacement::Right), + "up" => Some(ForkPanePlacement::Up), + "down" => Some(ForkPanePlacement::Down), + "float" => Some(ForkPanePlacement::Float), + _ => None, + } +} + +fn codex_executable() -> PathBuf { + std::env::current_exe() + .map(|path| resolve_codex_executable(&path)) + .unwrap_or_else(|_| PathBuf::from("codex")) +} + +fn resolve_codex_executable(current_exe: &Path) -> PathBuf { + let Some(file_name) = current_exe.file_name().and_then(|name| name.to_str()) else { + return PathBuf::from("codex"); + }; + let Some(base_name) = file_name + .strip_suffix(".exe") + .unwrap_or(file_name) + .strip_prefix("codex-tui") + else { + return current_exe.to_path_buf(); + }; + if !base_name.is_empty() { + return current_exe.to_path_buf(); + } + + let sibling = if file_name.ends_with(".exe") { + current_exe.with_file_name("codex.exe") + } else { + current_exe.with_file_name("codex") + }; + + if sibling.is_file() { + sibling + } else { + PathBuf::from("codex") + } +} + +fn fork_command_parts( + exe: &Path, + thread_id: &ThreadId, + config: &Config, + additional_writable_roots: &[PathBuf], +) -> Vec { + let mut args = vec![ + exe.display().to_string(), + "fork".to_string(), + "-C".to_string(), + config.cwd.display().to_string(), + ]; + + match config.permissions.approval_policy.value() { + AskForApproval::UnlessTrusted => { + args.push("-a".to_string()); + args.push("untrusted".to_string()); + } + AskForApproval::OnFailure => { + args.push("-a".to_string()); + args.push("on-failure".to_string()); + } + AskForApproval::OnRequest => { + args.push("-a".to_string()); + args.push("on-request".to_string()); + } + AskForApproval::Never => { + args.push("-a".to_string()); + args.push("never".to_string()); + } + AskForApproval::Granular(granular_config) => { + let sandbox_approval = granular_config.sandbox_approval; + let rules = granular_config.rules; + let skill_approval = granular_config.skill_approval; + let request_permissions = granular_config.request_permissions; + let mcp_elicitations = granular_config.mcp_elicitations; + args.push("-c".to_string()); + args.push(format!( + "approval_policy={{ granular = {{ sandbox_approval = {sandbox_approval}, rules = {rules}, skill_approval = {skill_approval}, request_permissions = {request_permissions}, mcp_elicitations = {mcp_elicitations} }} }}" + )); + } + } + + if let Some(profile) = config.active_profile.as_deref() { + args.push("-p".to_string()); + args.push(profile.to_string()); + } + if let Some(model) = config.model.as_deref() { + args.push("-m".to_string()); + args.push(model.to_string()); + } + if let Some(sandbox_mode) = sandbox_mode_arg(config.permissions.sandbox_policy.get()) { + args.push("-s".to_string()); + args.push(sandbox_mode.to_string()); + } + if config.web_search_mode.value() == WebSearchMode::Live { + args.push("--search".to_string()); + } + for root in additional_writable_roots { + args.push("--add-dir".to_string()); + args.push(root.display().to_string()); + } + args.push(thread_id.to_string()); + + args +} + +fn sandbox_mode_arg(policy: &SandboxPolicy) -> Option<&'static str> { + match policy { + SandboxPolicy::DangerFullAccess => Some("danger-full-access"), + SandboxPolicy::ReadOnly { .. } => Some("read-only"), + SandboxPolicy::WorkspaceWrite { .. } => Some("workspace-write"), + SandboxPolicy::ExternalSandbox { .. } => None, + } +} + +fn zellij_direction(placement: ForkPanePlacement) -> Option<&'static str> { + match placement { + ForkPanePlacement::Right => Some("right"), + ForkPanePlacement::Down => Some("down"), + _ => None, + } +} + +fn build_zellij_new_pane_args( + command: &[String], + thread_id: &ThreadId, + placement: Option, +) -> Vec { + let mut args = vec![ + "action".to_string(), + "new-pane".to_string(), + "--close-on-exit".to_string(), + ]; + args.push("--name".to_string()); + args.push(format!("Fork of {thread_id}")); + if let Some(placement) = placement { + if placement == ForkPanePlacement::Float { + args.push("--floating".to_string()); + } else if let Some(direction) = zellij_direction(placement) { + args.push("--direction".to_string()); + args.push(direction.to_string()); + } else { + unreachable!("invalid zellij placement"); + } + } + args.push("--".to_string()); + args.extend(command.iter().cloned()); + args +} + +fn tmux_split_flags(placement: Option) -> [&'static str; 2] { + match placement { + None | Some(ForkPanePlacement::Right) => ["-h", ""], + Some(ForkPanePlacement::Left) => ["-h", "-b"], + Some(ForkPanePlacement::Down) => ["-v", ""], + Some(ForkPanePlacement::Up) => ["-v", "-b"], + _ => unreachable!("invalid tmux placement"), + } +} + +fn build_tmux_new_pane_args( + command: &[String], + placement: Option, +) -> Vec { + let command = + try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); + let flags = tmux_split_flags(placement); + let mut args = vec!["split-window".to_string(), flags[0].to_string()]; + if !flags[1].is_empty() { + args.push(flags[1].to_string()); + } + args.push(command); + args +} + +fn fork_spawn_config( + multiplexer: &Multiplexer, + exe: &Path, + thread_id: &ThreadId, + config: &Config, + additional_writable_roots: &[PathBuf], + placement: Option, +) -> MultiplexerSpawnConfig { + let command = fork_command_parts(exe, thread_id, config, additional_writable_roots); + match multiplexer { + Multiplexer::Zellij {} => MultiplexerSpawnConfig { + program: PathBuf::from("zellij"), + args: build_zellij_new_pane_args(&command, thread_id, placement), + }, + Multiplexer::Tmux { .. } => MultiplexerSpawnConfig { + program: PathBuf::from("tmux"), + args: build_tmux_new_pane_args(&command, placement), + }, + } +} + +const TMUX_FLOAT_UNSUPPORTED_MESSAGE: &str = "tmux does not support /fork float."; +const ZELLIJ_UNSUPPORTED_MESSAGE: &str = "Zellij only supports /fork [float|right|down]."; +pub(crate) const FORK_PLACEMENT_REQUIRES_MULTIPLEXER_MESSAGE: &str = + "Fork pane placement requires a terminal multiplexer."; + +pub(crate) fn fork_command_usage(multiplexer: Option<&Multiplexer>) -> String { + let Some(multiplexer) = multiplexer else { + return "Usage: /fork".to_string(); + }; + let options = fork_pane_options(multiplexer); + if options.is_empty() { + return "Usage: /fork".to_string(); + } + + let options = options + .iter() + .map(|option| option.name) + .collect::>() + .join("|"); + format!("Usage: /fork [{options}]") +} + +fn validate_fork_placement_for_multiplexer( + multiplexer: &Multiplexer, + placement: Option, +) -> Result<(), String> { + match multiplexer { + Multiplexer::Zellij {} => { + if placement.is_none_or(|placement| { + ZELLIJ_FORK_PANE_OPTIONS + .iter() + .any(|option| option.placement == placement) + }) { + Ok(()) + } else { + Err(ZELLIJ_UNSUPPORTED_MESSAGE.to_string()) + } + } + Multiplexer::Tmux { .. } => { + if placement.is_none_or(|placement| { + TMUX_FORK_PANE_OPTIONS + .iter() + .any(|option| option.placement == placement) + }) { + Ok(()) + } else { + Err(TMUX_FLOAT_UNSUPPORTED_MESSAGE.to_string()) + } + } + } +} + +pub(crate) async fn spawn_fork_in_new_pane( + multiplexer: &Multiplexer, + thread_id: &ThreadId, + config: &Config, + additional_writable_roots: &[PathBuf], + placement: Option, +) -> ForkPaneSpawnResult { + if let Err(err) = validate_fork_placement_for_multiplexer(multiplexer, placement) { + return ForkPaneSpawnResult::InvalidPlacement(err); + } + + let exe = codex_executable(); + let spawn_config = fork_spawn_config( + multiplexer, + &exe, + thread_id, + config, + additional_writable_roots, + placement, + ); + let MultiplexerSpawnConfig { program, args } = spawn_config; + let program_display = program.display().to_string(); + match tokio::task::spawn_blocking(move || { + Command::new(&program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + }) + .await + { + Ok(Ok(_)) => ForkPaneSpawnResult::Spawned, + Ok(Err(err)) => { + ForkPaneSpawnResult::Failed(format!("failed to run {program_display}: {err}")) + } + Err(err) => { + ForkPaneSpawnResult::Failed(format!("failed to spawn {program_display} pane: {err}")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::config::ConfigBuilder; + use codex_protocol::protocol::GranularApprovalConfig; + use codex_utils_absolute_path::AbsolutePathBuf; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn resolve_codex_executable_prefers_sibling_codex_for_codex_tui() { + let tempdir = tempdir().expect("tempdir"); + let current_exe = tempdir.path().join("codex-tui"); + let sibling = tempdir.path().join("codex"); + std::fs::write(&sibling, b"").expect("create sibling codex"); + + assert_eq!(resolve_codex_executable(¤t_exe), sibling); + } + + #[test] + fn resolve_codex_executable_keeps_non_tui_binary() { + let current_exe = PathBuf::from("/tmp/codex"); + + assert_eq!(resolve_codex_executable(¤t_exe), current_exe); + } + + #[test] + fn validate_zellij_fork_placement_rejects_left() { + assert_eq!( + validate_fork_placement_for_multiplexer( + &Multiplexer::Zellij {}, + Some(ForkPanePlacement::Left), + ), + Err(ZELLIJ_UNSUPPORTED_MESSAGE.to_string()) + ); + } + + #[test] + fn validate_tmux_fork_placement_rejects_float() { + assert_eq!( + validate_fork_placement_for_multiplexer( + &Multiplexer::Tmux { version: None }, + Some(ForkPanePlacement::Float), + ), + Err(TMUX_FLOAT_UNSUPPORTED_MESSAGE.to_string()) + ); + } + + #[test] + fn fork_command_usage_is_contextual() { + assert_snapshot!( + "fork_command_usage_default", + fork_command_usage(/*multiplexer*/ None) + ); + assert_snapshot!( + "fork_command_usage_tmux", + fork_command_usage(Some(&Multiplexer::Tmux { version: None })) + ); + assert_snapshot!( + "fork_command_usage_zellij", + fork_command_usage(Some(&Multiplexer::Zellij {})) + ); + } + + #[tokio::test] + async fn fork_command_parts_include_current_session_overrides() { + let codex_home = tempdir().expect("temp codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + config.active_profile = Some("work".to_string()); + config.model = Some("gpt-5".to_string()); + config.cwd = + AbsolutePathBuf::from_absolute_path(PathBuf::from("/repo")).expect("absolute repo cwd"); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("approval policy"); + config + .permissions + .sandbox_policy + .set(SandboxPolicy::new_workspace_write_policy()) + .expect("sandbox policy"); + config + .web_search_mode + .set(WebSearchMode::Live) + .expect("web search mode"); + + let command = fork_command_parts( + Path::new("/bin/codex"), + &ThreadId::new(), + &config, + &[PathBuf::from("/extra")], + ); + let thread_id = command.last().expect("thread id").clone(); + + assert_eq!( + command, + vec![ + "/bin/codex".to_string(), + "fork".to_string(), + "-C".to_string(), + "/repo".to_string(), + "-a".to_string(), + "on-request".to_string(), + "-p".to_string(), + "work".to_string(), + "-m".to_string(), + "gpt-5".to_string(), + "-s".to_string(), + "workspace-write".to_string(), + "--search".to_string(), + "--add-dir".to_string(), + "/extra".to_string(), + thread_id, + ] + ); + } + + #[tokio::test] + async fn fork_command_parts_preserve_granular_approval_policy() { + let codex_home = tempdir().expect("temp codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + config.cwd = + AbsolutePathBuf::from_absolute_path(PathBuf::from("/repo")).expect("absolute repo cwd"); + config + .permissions + .approval_policy + .set(AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: false, + skill_approval: true, + request_permissions: false, + mcp_elicitations: true, + })) + .expect("approval policy"); + + let command = fork_command_parts(Path::new("/bin/codex"), &ThreadId::new(), &config, &[]); + let thread_id = command.last().expect("thread id").clone(); + + assert_eq!( + command, + vec![ + "/bin/codex".to_string(), + "fork".to_string(), + "-C".to_string(), + "/repo".to_string(), + "-c".to_string(), + "approval_policy={ granular = { sandbox_approval = true, rules = false, skill_approval = true, request_permissions = false, mcp_elicitations = true } }".to_string(), + "-s".to_string(), + "read-only".to_string(), + thread_id, + ] + ); + } +}