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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 113 additions and 135 deletions

View file

@ -758,73 +758,61 @@ impl App {
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
}
AppEvent::OpenForkPicker => {
match crate::resume_picker::run_fork_picker(
tui,
&self.config.codex_home,
&self.config.model_provider_id,
false,
)
.await?
{
SessionSelection::Fork(path) => {
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
);
match self
.server
.fork_thread(usize::MAX, self.config.clone(), path.clone())
.await
{
Ok(forked) => {
self.shutdown_current_thread().await;
let init = crate::chatwidget::ChatWidgetInit {
config: self.config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
initial_prompt: None,
initial_images: Vec::new(),
enhanced_keys_supported: self.enhanced_keys_supported,
auth_manager: self.auth_manager.clone(),
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
model: Some(self.current_model.clone()),
};
self.chat_widget = ChatWidget::new_from_existing(
init,
forked.thread,
forked.session_configured,
);
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);
AppEvent::ForkCurrentSession => {
let summary =
session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id());
if let Some(path) = self.chat_widget.rollout_path() {
match self
.server
.fork_thread(usize::MAX, self.config.clone(), path.clone())
.await
{
Ok(forked) => {
self.shutdown_current_thread().await;
let init = crate::chatwidget::ChatWidgetInit {
config: self.config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
initial_prompt: None,
initial_images: Vec::new(),
enhanced_keys_supported: self.enhanced_keys_supported,
auth_manager: self.auth_manager.clone(),
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
model: Some(self.current_model.clone()),
};
self.chat_widget = ChatWidget::new_from_existing(
init,
forked.thread,
forked.session_configured,
);
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());
}
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork session from {path_display}: {err}"
));
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 current session from {path_display}: {err}"
));
}
}
SessionSelection::Exit
| SessionSelection::StartFresh
| SessionSelection::Resume(_) => {}
} else {
self.chat_widget
.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();
}
AppEvent::InsertHistoryCell(cell) => {

View file

@ -53,8 +53,8 @@ pub(crate) enum AppEvent {
/// Open the resume picker inside the running TUI session.
OpenResumePicker,
/// Open the fork picker inside the running TUI session.
OpenForkPicker,
/// Fork the current session into a new thread.
ForkCurrentSession,
/// Request to exit the application.
///

View file

@ -1994,7 +1994,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::OpenResumePicker);
}
SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::OpenForkPicker);
self.app_event_tx.send(AppEvent::ForkCurrentSession);
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);

View file

@ -1532,12 +1532,12 @@ async fn slash_resume_opens_picker() {
}
#[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;
chat.dispatch_command(SlashCommand::Fork);
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenForkPicker));
assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession));
}
#[tokio::test]

View file

@ -48,7 +48,7 @@ impl SlashCommand {
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues",
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::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",

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.
Use /skills to list available skills or ask Codex to use one.
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 /mcp to list configured MCP tools.
You can run any shell command from Codex using `!` (e.g. `!ls`)

View file

@ -1531,72 +1531,62 @@ impl App {
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
}
AppEvent::OpenForkPicker => {
match crate::resume_picker::run_fork_picker(
tui,
&self.config.codex_home,
&self.config.model_provider_id,
false,
)
.await?
{
SessionSelection::Fork(path) => {
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.conversation_id(),
);
match self
.server
.fork_thread(usize::MAX, self.config.clone(), path.clone())
.await
{
Ok(forked) => {
self.shutdown_current_conversation().await;
let init = crate::chatwidget::ChatWidgetInit {
config: self.config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
initial_prompt: None,
initial_images: Vec::new(),
enhanced_keys_supported: self.enhanced_keys_supported,
auth_manager: self.auth_manager.clone(),
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
model: Some(self.current_model.clone()),
};
self.chat_widget = ChatWidget::new_from_existing(
init,
forked.thread,
forked.session_configured,
);
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);
AppEvent::ForkCurrentSession => {
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.conversation_id(),
);
if let Some(path) = self.chat_widget.rollout_path() {
match self
.server
.fork_thread(usize::MAX, self.config.clone(), path.clone())
.await
{
Ok(forked) => {
self.shutdown_current_conversation().await;
let init = crate::chatwidget::ChatWidgetInit {
config: self.config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
initial_prompt: None,
initial_images: Vec::new(),
enhanced_keys_supported: self.enhanced_keys_supported,
auth_manager: self.auth_manager.clone(),
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
model: Some(self.current_model.clone()),
};
self.chat_widget = ChatWidget::new_from_existing(
init,
forked.thread,
forked.session_configured,
);
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());
}
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork session from {path_display}: {err}"
));
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 current session from {path_display}: {err}"
));
}
}
SessionSelection::Exit
| SessionSelection::StartFresh
| SessionSelection::Resume(_) => {}
} else {
self.chat_widget
.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();
}
AppEvent::InsertHistoryCell(cell) => {

View file

@ -47,8 +47,8 @@ pub(crate) enum AppEvent {
/// Open the resume picker inside the running TUI session.
OpenResumePicker,
/// Open the fork picker inside the running TUI session.
OpenForkPicker,
/// Fork the current session into a new thread.
ForkCurrentSession,
/// Request to exit the application.
///

View file

@ -1769,7 +1769,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::OpenResumePicker);
}
SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::OpenForkPicker);
self.app_event_tx.send(AppEvent::ForkCurrentSession);
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);

View file

@ -1338,12 +1338,12 @@ async fn slash_resume_opens_picker() {
}
#[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;
chat.dispatch_command(SlashCommand::Fork);
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenForkPicker));
assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession));
}
#[tokio::test]

View file

@ -46,7 +46,7 @@ impl SlashCommand {
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues",
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::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",

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.
Use /skills to list available skills or ask Codex to use one.
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 /mcp to list configured MCP tools.
You can run any shell command from Codex using `!` (e.g. `!ls`)