mirror of
https://github.com/openai/codex.git
synced 2026-05-12 15:22:39 +00:00
Compare commits
1 Commits
pr20460
...
re/fork-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3253c3721f |
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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}"))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -145,6 +145,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Side
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::SandboxReadRoot
|
||||
| SlashCommand::Fork
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/terminal_multiplexer.rs
|
||||
assertion_line: 367
|
||||
expression: fork_command_usage(None)
|
||||
---
|
||||
Usage: /fork
|
||||
@@ -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]
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/terminal_multiplexer.rs
|
||||
expression: "fork_command_usage(Some(&Multiplexer::Zellij {}))"
|
||||
---
|
||||
Usage: /fork [float|right|down]
|
||||
536
codex-rs/tui/src/terminal_multiplexer.rs
Normal file
536
codex-rs/tui/src/terminal_multiplexer.rs
Normal 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(¤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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user