mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
Fork sessions into new multiplexer panes
Add pane-aware /fork support for tmux and zellij. Launch forked sessions in a new multiplexer pane with the top-level codex fork flow, preserve CLI-expressible session overrides, and expose multiplexer-specific placement options through picker/help flows.
This commit is contained in:
+42
-3
@@ -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;
|
||||
|
||||
@@ -108,7 +108,9 @@ pub(crate) enum AppEvent {
|
||||
OpenResumePicker,
|
||||
|
||||
/// Fork the current session into a new thread.
|
||||
ForkCurrentSession,
|
||||
ForkCurrentSession {
|
||||
placement: Option<ForkPanePlacement>,
|
||||
},
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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<SelectionAction> = 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
|
||||
|
||||
+13
@@ -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
|
||||
+12
@@ -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
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -132,6 +132,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::Fork
|
||||
| SlashCommand::SandboxReadRoot
|
||||
)
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/terminal_multiplexer.rs
|
||||
assertion_line: 367
|
||||
expression: fork_command_usage(None)
|
||||
---
|
||||
Usage: /fork
|
||||
+5
@@ -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]
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/terminal_multiplexer.rs
|
||||
expression: "fork_command_usage(Some(&Multiplexer::Zellij {}))"
|
||||
---
|
||||
Usage: /fork [float|right|down]
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<ForkPanePlacement> {
|
||||
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<String> {
|
||||
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<ForkPanePlacement>,
|
||||
) -> Vec<String> {
|
||||
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<ForkPanePlacement>) -> [&'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<ForkPanePlacement>,
|
||||
) -> Vec<String> {
|
||||
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<ForkPanePlacement>,
|
||||
) -> 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::<Vec<_>>()
|
||||
.join("|");
|
||||
format!("Usage: /fork [{options}]")
|
||||
}
|
||||
|
||||
fn validate_fork_placement_for_multiplexer(
|
||||
multiplexer: &Multiplexer,
|
||||
placement: Option<ForkPanePlacement>,
|
||||
) -> 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<ForkPanePlacement>,
|
||||
) -> 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user