feat: /fork the current session instead of opening session picker (#9385)

Implemented /fork to fork the current session directly (no picker),
handling it via a new ForkCurrentSession app event in both tui and tui2.
Updated slash command descriptions/tooltips and adjusted the fork tests
accordingly. Removed the unused in-session fork picker event.
This commit is contained in:
Anton Panasenko
2026-01-16 11:28:52 -08:00
committed by GitHub
parent f89a40a849
commit e893e83eb9
12 changed files with 113 additions and 135 deletions
+49 -61
View File
@@ -758,73 +758,61 @@ impl App {
// Leaving alt-screen may blank the inline viewport; force a redraw either way. // Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame(); tui.frame_requester().schedule_frame();
} }
AppEvent::OpenForkPicker => { AppEvent::ForkCurrentSession => {
match crate::resume_picker::run_fork_picker( let summary =
tui, session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id());
&self.config.codex_home, if let Some(path) = self.chat_widget.rollout_path() {
&self.config.model_provider_id, match self
false, .server
) .fork_thread(usize::MAX, self.config.clone(), path.clone())
.await? .await
{ {
SessionSelection::Fork(path) => { Ok(forked) => {
let summary = session_summary( self.shutdown_current_thread().await;
self.chat_widget.token_usage(), let init = crate::chatwidget::ChatWidgetInit {
self.chat_widget.thread_id(), config: self.config.clone(),
); frame_requester: tui.frame_requester(),
match self app_event_tx: self.app_event_tx.clone(),
.server initial_prompt: None,
.fork_thread(usize::MAX, self.config.clone(), path.clone()) initial_images: Vec::new(),
.await enhanced_keys_supported: self.enhanced_keys_supported,
{ auth_manager: self.auth_manager.clone(),
Ok(forked) => { models_manager: self.server.get_models_manager(),
self.shutdown_current_thread().await; feedback: self.feedback.clone(),
let init = crate::chatwidget::ChatWidgetInit { is_first_run: false,
config: self.config.clone(), model: Some(self.current_model.clone()),
frame_requester: tui.frame_requester(), };
app_event_tx: self.app_event_tx.clone(), self.chat_widget = ChatWidget::new_from_existing(
initial_prompt: None, init,
initial_images: Vec::new(), forked.thread,
enhanced_keys_supported: self.enhanced_keys_supported, forked.session_configured,
auth_manager: self.auth_manager.clone(), );
models_manager: self.server.get_models_manager(), self.current_model = model_info.slug.clone();
feedback: self.feedback.clone(), if let Some(summary) = summary {
is_first_run: false, let mut lines: Vec<Line<'static>> =
model: Some(self.current_model.clone()), vec![summary.usage_line.clone().into()];
}; if let Some(command) = summary.resume_command {
self.chat_widget = ChatWidget::new_from_existing( let spans = vec![
init, "To continue this session, run ".into(),
forked.thread, command.cyan(),
forked.session_configured, ];
); lines.push(spans.into());
self.current_model = model_info.slug.clone();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
} }
} self.chat_widget.add_plain_history_lines(lines);
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork session from {path_display}: {err}"
));
} }
} }
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork current session from {path_display}: {err}"
));
}
} }
SessionSelection::Exit } else {
| SessionSelection::StartFresh self.chat_widget
| SessionSelection::Resume(_) => {} .add_error_message("Current session is not ready to fork yet.".to_string());
} }
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame(); tui.frame_requester().schedule_frame();
} }
AppEvent::InsertHistoryCell(cell) => { AppEvent::InsertHistoryCell(cell) => {
+2 -2
View File
@@ -53,8 +53,8 @@ pub(crate) enum AppEvent {
/// Open the resume picker inside the running TUI session. /// Open the resume picker inside the running TUI session.
OpenResumePicker, OpenResumePicker,
/// Open the fork picker inside the running TUI session. /// Fork the current session into a new thread.
OpenForkPicker, ForkCurrentSession,
/// Request to exit the application. /// Request to exit the application.
/// ///
+1 -1
View File
@@ -1994,7 +1994,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::OpenResumePicker); self.app_event_tx.send(AppEvent::OpenResumePicker);
} }
SlashCommand::Fork => { SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::OpenForkPicker); self.app_event_tx.send(AppEvent::ForkCurrentSession);
} }
SlashCommand::Init => { SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);
+2 -2
View File
@@ -1532,12 +1532,12 @@ async fn slash_resume_opens_picker() {
} }
#[tokio::test] #[tokio::test]
async fn slash_fork_opens_picker() { async fn slash_fork_requests_current_fork() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Fork); chat.dispatch_command(SlashCommand::Fork);
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenForkPicker)); assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession));
} }
#[tokio::test] #[tokio::test]
+1 -1
View File
@@ -48,7 +48,7 @@ impl SlashCommand {
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues", SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Resume => "resume a saved chat", SlashCommand::Resume => "resume a saved chat",
SlashCommand::Fork => "fork a saved chat", SlashCommand::Fork => "fork the current chat",
// SlashCommand::Undo => "ask Codex to undo a turn", // SlashCommand::Undo => "ask Codex to undo a turn",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Diff => "show git diff (including untracked files)",
+1 -1
View File
@@ -6,7 +6,7 @@ Use /approvals to control when Codex asks for confirmation.
Run /review to get a code review of your current changes. Run /review to get a code review of your current changes.
Use /skills to list available skills or ask Codex to use one. Use /skills to list available skills or ask Codex to use one.
Use /status to see the current model, approvals, and token usage. Use /status to see the current model, approvals, and token usage.
Use /fork to branch a saved chat into a new thread. Use /fork to branch the current chat into a new thread.
Use /init to create an AGENTS.md with project-specific guidance. Use /init to create an AGENTS.md with project-specific guidance.
Use /mcp to list configured MCP tools. Use /mcp to list configured MCP tools.
You can run any shell command from Codex using `!` (e.g. `!ls`) You can run any shell command from Codex using `!` (e.g. `!ls`)
+50 -60
View File
@@ -1531,72 +1531,62 @@ impl App {
// Leaving alt-screen may blank the inline viewport; force a redraw either way. // Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame(); tui.frame_requester().schedule_frame();
} }
AppEvent::OpenForkPicker => { AppEvent::ForkCurrentSession => {
match crate::resume_picker::run_fork_picker( let summary = session_summary(
tui, self.chat_widget.token_usage(),
&self.config.codex_home, self.chat_widget.conversation_id(),
&self.config.model_provider_id, );
false, if let Some(path) = self.chat_widget.rollout_path() {
) match self
.await? .server
{ .fork_thread(usize::MAX, self.config.clone(), path.clone())
SessionSelection::Fork(path) => { .await
let summary = session_summary( {
self.chat_widget.token_usage(), Ok(forked) => {
self.chat_widget.conversation_id(), self.shutdown_current_conversation().await;
); let init = crate::chatwidget::ChatWidgetInit {
match self config: self.config.clone(),
.server frame_requester: tui.frame_requester(),
.fork_thread(usize::MAX, self.config.clone(), path.clone()) app_event_tx: self.app_event_tx.clone(),
.await initial_prompt: None,
{ initial_images: Vec::new(),
Ok(forked) => { enhanced_keys_supported: self.enhanced_keys_supported,
self.shutdown_current_conversation().await; auth_manager: self.auth_manager.clone(),
let init = crate::chatwidget::ChatWidgetInit { models_manager: self.server.get_models_manager(),
config: self.config.clone(), feedback: self.feedback.clone(),
frame_requester: tui.frame_requester(), is_first_run: false,
app_event_tx: self.app_event_tx.clone(), model: Some(self.current_model.clone()),
initial_prompt: None, };
initial_images: Vec::new(), self.chat_widget = ChatWidget::new_from_existing(
enhanced_keys_supported: self.enhanced_keys_supported, init,
auth_manager: self.auth_manager.clone(), forked.thread,
models_manager: self.server.get_models_manager(), forked.session_configured,
feedback: self.feedback.clone(), );
is_first_run: false, if let Some(summary) = summary {
model: Some(self.current_model.clone()), let mut lines: Vec<Line<'static>> =
}; vec![summary.usage_line.clone().into()];
self.chat_widget = ChatWidget::new_from_existing( if let Some(command) = summary.resume_command {
init, let spans = vec![
forked.thread, "To continue this session, run ".into(),
forked.session_configured, command.cyan(),
); ];
if let Some(summary) = summary { lines.push(spans.into());
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
} }
} self.chat_widget.add_plain_history_lines(lines);
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork session from {path_display}: {err}"
));
} }
} }
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork current session from {path_display}: {err}"
));
}
} }
SessionSelection::Exit } else {
| SessionSelection::StartFresh self.chat_widget
| SessionSelection::Resume(_) => {} .add_error_message("Current session is not ready to fork yet.".to_string());
} }
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame(); tui.frame_requester().schedule_frame();
} }
AppEvent::InsertHistoryCell(cell) => { AppEvent::InsertHistoryCell(cell) => {
+2 -2
View File
@@ -47,8 +47,8 @@ pub(crate) enum AppEvent {
/// Open the resume picker inside the running TUI session. /// Open the resume picker inside the running TUI session.
OpenResumePicker, OpenResumePicker,
/// Open the fork picker inside the running TUI session. /// Fork the current session into a new thread.
OpenForkPicker, ForkCurrentSession,
/// Request to exit the application. /// Request to exit the application.
/// ///
+1 -1
View File
@@ -1769,7 +1769,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::OpenResumePicker); self.app_event_tx.send(AppEvent::OpenResumePicker);
} }
SlashCommand::Fork => { SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::OpenForkPicker); self.app_event_tx.send(AppEvent::ForkCurrentSession);
} }
SlashCommand::Init => { SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);
+2 -2
View File
@@ -1338,12 +1338,12 @@ async fn slash_resume_opens_picker() {
} }
#[tokio::test] #[tokio::test]
async fn slash_fork_opens_picker() { async fn slash_fork_requests_current_fork() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Fork); chat.dispatch_command(SlashCommand::Fork);
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenForkPicker)); assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession));
} }
#[tokio::test] #[tokio::test]
+1 -1
View File
@@ -46,7 +46,7 @@ impl SlashCommand {
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues", SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Resume => "resume a saved chat", SlashCommand::Resume => "resume a saved chat",
SlashCommand::Fork => "fork a saved chat", SlashCommand::Fork => "fork the current chat",
// SlashCommand::Undo => "ask Codex to undo a turn", // SlashCommand::Undo => "ask Codex to undo a turn",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Diff => "show git diff (including untracked files)",
+1 -1
View File
@@ -6,7 +6,7 @@ Use /approvals to control when Codex asks for confirmation.
Run /review to get a code review of your current changes. Run /review to get a code review of your current changes.
Use /skills to list available skills or ask Codex to use one. Use /skills to list available skills or ask Codex to use one.
Use /status to see the current model, approvals, and token usage. Use /status to see the current model, approvals, and token usage.
Use /fork to branch a saved chat into a new thread. Use /fork to branch the current chat into a new thread.
Use /init to create an AGENTS.md with project-specific guidance. Use /init to create an AGENTS.md with project-specific guidance.
Use /mcp to list configured MCP tools. Use /mcp to list configured MCP tools.
You can run any shell command from Codex using `!` (e.g. `!ls`) You can run any shell command from Codex using `!` (e.g. `!ls`)