Compare commits

...

1 Commits

Author SHA1 Message Date
Rakan El Khalil
3253c3721f 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.

Co-authored-by: Codex <noreply@openai.com>
2026-04-25 18:46:55 -07:00
21 changed files with 756 additions and 43 deletions

View File

@@ -65,6 +65,9 @@ use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::SessionSelection;
use crate::resume_picker::SessionTarget;
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;
#[cfg(test)]
@@ -145,6 +148,7 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillErrorInfo;
use codex_protocol::protocol::TokenUsage;
use codex_terminal_detection::terminal_info;
use codex_terminal_detection::user_agent;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Result;

View File

@@ -115,7 +115,7 @@ impl App {
}
}
}
AppEvent::ForkCurrentSession => {
AppEvent::ForkCurrentSession { placement } => {
self.session_telemetry.counter(
"codex.thread.fork",
/*inc*/ 1,
@@ -127,11 +127,46 @@ impl App {
self.chat_widget.thread_name(),
self.chat_widget.rollout_path().as_deref(),
);
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;

View File

@@ -176,14 +176,10 @@ impl App {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
if self.chat_widget.is_normal_backtrack_mode()
&& self.chat_widget.composer_is_empty()
{
self.handle_backtrack_esc_key(tui);
} else {
self.chat_widget.handle_key_event(key_event);
}
} if self.chat_widget.is_normal_backtrack_mode()
&& self.chat_widget.composer_is_empty() =>
{
self.handle_backtrack_esc_key(tui);
}
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
KeyEvent {

View File

@@ -104,10 +104,10 @@ impl ThreadEventStore {
ServerNotification::TurnStarted(turn) => {
self.active_turn_id = Some(turn.turn.id.clone());
}
ServerNotification::TurnCompleted(turn) => {
if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) {
self.active_turn_id = None;
}
ServerNotification::TurnCompleted(turn)
if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) =>
{
self.active_turn_id = None;
}
ServerNotification::ThreadClosed(_) => {
self.active_turn_id = None;

View File

@@ -155,7 +155,9 @@ pub(crate) enum AppEvent {
ResumeSessionByIdOrName(String),
/// Fork the current session into a new thread.
ForkCurrentSession,
ForkCurrentSession {
placement: Option<ForkPanePlacement>,
},
/// Request to exit the application.
///
@@ -736,6 +738,15 @@ pub(crate) enum AppEvent {
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ForkPanePlacement {
Left,
Right,
Up,
Down,
Float,
}
#[derive(Debug)]
pub(crate) struct RealtimeWebrtcOffer {
pub(crate) offer_sdp: String,

View File

@@ -1132,11 +1132,9 @@ impl BottomPaneView for RequestUserInputOverlay {
KeyCode::Backspace | KeyCode::Delete => {
self.clear_selection();
}
KeyCode::Tab => {
if self.selected_option_index().is_some() {
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
}
KeyCode::Tab if self.selected_option_index().is_some() => {
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
}
KeyCode::Enter => {
let has_selection = self.selected_option_index().is_some();

View File

@@ -73,6 +73,7 @@ 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_pane_options;
use crate::terminal_title::SetTerminalTitleResult;
use crate::terminal_title::clear_terminal_title;
use crate::terminal_title::set_terminal_title;
@@ -7378,10 +7379,8 @@ impl ChatWidget {
reasoning_effort,
agents_states,
}),
ThreadItem::EnteredReviewMode { review, .. } => {
if !from_replay {
self.enter_review_mode_with_hint(review, /*from_replay*/ false);
}
ThreadItem::EnteredReviewMode { review, .. } if !from_replay => {
self.enter_review_mode_with_hint(review, /*from_replay*/ false);
}
_ => {}
}
@@ -9473,6 +9472,36 @@ impl ChatWidget {
self.open_permissions_popup();
}
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()
});
}
/// Open a popup to choose the permissions mode (approval policy + sandbox policy).
pub(crate) fn open_permissions_popup(&mut self) {
let include_read_only = cfg!(target_os = "windows");

View File

@@ -9,6 +9,10 @@ use super::*;
use crate::app_event::ThreadGoalSetMode;
use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::bottom_pane::slash_commands;
use crate::terminal_multiplexer::fork_command_usage;
use crate::terminal_multiplexer::parse_fork_pane_placement;
use codex_terminal_detection::Multiplexer;
use codex_terminal_detection::terminal_info;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlashCommandDispatchSource {
@@ -103,6 +107,15 @@ impl ChatWidget {
self.request_side_conversation(parent_thread_id, /*user_message*/ None);
}
pub(super) 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 });
}
}
pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) {
if !self.ensure_slash_command_allowed_in_side_conversation(cmd) {
return;
@@ -145,7 +158,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 = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
@@ -600,6 +613,23 @@ 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::Goal if !trimmed.is_empty() => {
if !self.config.features.enabled(Feature::Goals) {
return;

View File

@@ -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

View File

@@ -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

View File

@@ -1847,9 +1847,42 @@ async fn slash_resume_with_arg_requests_named_session() {
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]

View File

@@ -1156,7 +1156,7 @@ fn with_border_internal(
let span_count = line.spans.len();
let mut spans: Vec<Span<'static>> = Vec::with_capacity(span_count + 4);
spans.push(Span::from("").dim());
spans.extend(line.into_iter());
spans.extend(line);
if used_width < content_width {
spans.push(Span::from(" ".repeat(content_width - used_width)).dim());
}
@@ -2001,7 +2001,7 @@ pub(crate) fn new_mcp_tools_output(
let effective_servers = config.mcp_servers.get().clone();
let mut servers: Vec<_> = effective_servers.iter().collect();
servers.sort_by(|(a, _), (b, _)| a.cmp(b));
servers.sort_by_key(|(a, _)| *a);
for (server, cfg) in servers {
let prefix = qualified_mcp_tool_name_prefix(server);
@@ -2067,7 +2067,7 @@ pub(crate) fn new_mcp_tools_output(
&& !headers.is_empty()
{
let mut pairs: Vec<_> = headers.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs.sort_by_key(|(a, _)| *a);
let display = pairs
.into_iter()
.map(|(name, _)| format!("{name}=*****"))
@@ -2079,7 +2079,7 @@ pub(crate) fn new_mcp_tools_output(
&& !headers.is_empty()
{
let mut pairs: Vec<_> = headers.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs.sort_by_key(|(a, _)| *a);
let display = pairs
.into_iter()
.map(|(name, var)| format!("{name}={var}"))
@@ -2248,7 +2248,7 @@ pub(crate) fn new_mcp_tools_output_from_statuses(
&& !headers.is_empty()
{
let mut pairs: Vec<_> = headers.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs.sort_by_key(|(a, _)| *a);
let display = pairs
.into_iter()
.map(|(name, _)| format!("{name}=*****"))
@@ -2260,7 +2260,7 @@ pub(crate) fn new_mcp_tools_output_from_statuses(
&& !headers.is_empty()
{
let mut pairs: Vec<_> = headers.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs.sort_by_key(|(a, _)| *a);
let display = pairs
.into_iter()
.map(|(name, var)| format!("{name}={var}"))

View File

@@ -160,6 +160,7 @@ mod status;
mod status_indicator_widget;
mod streaming;
mod style;
mod terminal_multiplexer;
mod terminal_palette;
mod terminal_title;
mod text_formatting;

View File

@@ -493,7 +493,7 @@ fn wait_complete_lines(
status: status.clone(),
})
.collect::<Vec<_>>();
entries.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string()));
entries.sort_by_key(|left| left.thread_id.to_string());
entries
} else {
let mut entries = agent_statuses.to_vec();
@@ -511,7 +511,7 @@ fn wait_complete_lines(
status: status.clone(),
})
.collect::<Vec<_>>();
extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string()));
extras.sort_by_key(|left| left.thread_id.to_string());
entries.extend(extras);
entries
};

View File

@@ -625,8 +625,8 @@ impl PickerState {
KeyEvent {
code: KeyCode::PageDown,
..
} => {
if !self.filtered_rows.is_empty() {
}
if !self.filtered_rows.is_empty() => {
let step = self.view_rows.unwrap_or(10).max(1);
let max_index = self.filtered_rows.len().saturating_sub(1);
self.selected = (self.selected + step).min(max_index);
@@ -634,7 +634,6 @@ impl PickerState {
self.maybe_load_more_for_scroll();
self.request_frame();
}
}
KeyEvent {
code: KeyCode::Tab, ..
} => {
@@ -653,16 +652,15 @@ impl PickerState {
code: KeyCode::Char(c),
modifiers,
..
} => {
}
// basic text input for search
if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT)
{
=> {
let mut new_query = self.query.clone();
new_query.push(c);
self.set_query(new_query);
}
}
_ => {}
}
Ok(None)

View File

@@ -145,6 +145,7 @@ impl SlashCommand {
| SlashCommand::Side
| SlashCommand::Resume
| SlashCommand::SandboxReadRoot
| SlashCommand::Fork
)
}

View File

@@ -0,0 +1,6 @@
---
source: tui/src/terminal_multiplexer.rs
assertion_line: 367
expression: fork_command_usage(None)
---
Usage: /fork

View File

@@ -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]

View File

@@ -0,0 +1,5 @@
---
source: tui/src/terminal_multiplexer.rs
expression: "fork_command_usage(Some(&Multiplexer::Zellij {}))"
---
Usage: /fork [float|right|down]

View File

@@ -0,0 +1,536 @@
use crate::app_event::ForkPanePlacement;
use crate::legacy_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 crate::legacy_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(&current_exe), sibling);
}
#[test]
fn resolve_codex_executable_keeps_non_tui_binary() {
let current_exe = PathBuf::from("/tmp/codex");
assert_eq!(resolve_codex_executable(&current_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,
]
);
}
}

View File

@@ -284,7 +284,7 @@ pub(crate) fn center_truncate_path(path: &str, max_width: usize) -> String {
}
};
for (left_count, right_count) in prioritized.into_iter().chain(fallback.into_iter()) {
for (left_count, right_count) in prioritized.into_iter().chain(fallback) {
let mut segments: Vec<Segment<'_>> = raw_segments[..left_count]
.iter()
.map(|seg| Segment {