feat: wire fork to codex cli (#8994)

## Summary
- add `codex fork` subcommand and `/fork` slash command mirroring resume
- extend session picker to support fork/resume actions with dynamic
labels in tui/tui2
- wire fork selection flow through tui bootstraps and add fork-related
tests
This commit is contained in:
Anton Panasenko authored and GitHub committed 2026-01-12 10:09:11 -08:00
1 parent 898e5f82f0
commit 4223948cf5
17 files changed
+754 -124

No files matched your search

+104 -10
View File
@@ -19,7 +19,7 @@ use crate::model_migration::run_model_migration_prompt;
use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::ResumeSelection;
use crate::resume_picker::SessionSelection;
use crate::transcript_copy_action::TranscriptCopyAction;
use crate::transcript_copy_action::TranscriptCopyFeedback;
use crate::transcript_copy_ui::TranscriptCopyUi;
@@ -403,7 +403,7 @@ impl App {
active_profile: Option<String>,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
resume_selection: ResumeSelection,
session_selection: SessionSelection,
feedback: codex_feedback::CodexFeedback,
is_first_run: bool,
) -> Result<AppExitInfo> {
@@ -436,8 +436,8 @@ impl App {
}
let enhanced_keys_supported = tui.enhanced_keys_supported();
let mut chat_widget = match resume_selection {
ResumeSelection::StartFresh | ResumeSelection::Exit => {
let mut chat_widget = match session_selection {
SessionSelection::StartFresh | SessionSelection::Exit => {
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
@@ -453,12 +453,13 @@ impl App {
};
ChatWidget::new(init, thread_manager.clone())
}
ResumeSelection::Resume(path) => {
SessionSelection::Resume(path) => {
let resumed = thread_manager
.resume_thread_from_rollout(config.clone(), path.clone(), auth_manager.clone())
.await
.wrap_err_with(|| {
format!("Failed to resume session from {}", path.display())
let path_display = path.display();
format!("Failed to resume session from {path_display}")
})?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
@@ -475,6 +476,29 @@ impl App {
};
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured)
}
SessionSelection::Fork(path) => {
let forked = thread_manager
.fork_thread(usize::MAX, config.clone(), path.clone())
.await
.wrap_err_with(|| {
let path_display = path.display();
format!("Failed to fork session from {path_display}")
})?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_prompt: initial_prompt.clone(),
initial_images: initial_images.clone(),
enhanced_keys_supported,
auth_manager: auth_manager.clone(),
models_manager: thread_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
model: model.clone(),
};
ChatWidget::new_from_existing(init, forked.thread, forked.session_configured)
}
};
chat_widget.maybe_prompt_windows_sandbox_enable();
@@ -1390,7 +1414,7 @@ impl App {
)
.await?
{
ResumeSelection::Resume(path) => {
SessionSelection::Resume(path) => {
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.conversation_id(),
@@ -1438,14 +1462,84 @@ impl App {
}
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to resume session from {}: {err}",
path.display()
"Failed to resume session from {path_display}: {err}"
));
}
}
}
ResumeSelection::Exit | ResumeSelection::StartFresh => {}
SessionSelection::Exit
| SessionSelection::StartFresh
| SessionSelection::Fork(_) => {}
}
// 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: 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);
}
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork session from {path_display}: {err}"
));
}
}
}
SessionSelection::Exit
| SessionSelection::StartFresh
| SessionSelection::Resume(_) => {}
}
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
+3
View File
@@ -38,6 +38,9 @@ pub(crate) enum AppEvent {
/// Open the resume picker inside the running TUI session.
OpenResumePicker,
/// Open the fork picker inside the running TUI session.
OpenForkPicker,
/// Request to exit the application gracefully.
ExitRequest,
+3
View File
@@ -1547,6 +1547,9 @@ impl ChatWidget {
SlashCommand::Resume => {
self.app_event_tx.send(AppEvent::OpenResumePicker);
}
SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::OpenForkPicker);
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);
if init_target.exists() {
+9
View File
@@ -1302,6 +1302,15 @@ async fn slash_resume_opens_picker() {
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker));
}
#[tokio::test]
async fn slash_fork_opens_picker() {
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));
}
#[tokio::test]
async fn slash_rollout_displays_current_path() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
+21
View File
@@ -32,6 +32,23 @@ pub struct Cli {
#[clap(skip)]
pub resume_show_all: bool,
// Internal controls set by the top-level `codex fork` subcommand.
// These are not exposed as user flags on the base `codex` command.
#[clap(skip)]
pub fork_picker: bool,
#[clap(skip)]
pub fork_last: bool,
/// Internal: fork a specific recorded session by id (UUID). Set by the
/// top-level `codex fork <SESSION_ID>` wrapper; not exposed as a public flag.
#[clap(skip)]
pub fork_session_id: Option<String>,
/// Internal: show all sessions (disables cwd filtering and shows CWD column).
#[clap(skip)]
pub fork_show_all: bool,
/// Model the agent should use.
#[arg(long, short = 'm')]
pub model: Option<String>,
@@ -103,6 +120,10 @@ impl From<codex_tui::Cli> for Cli {
resume_last: cli.resume_last,
resume_session_id: cli.resume_session_id,
resume_show_all: cli.resume_show_all,
fork_picker: cli.fork_picker,
fork_last: cli.fork_last,
fork_session_id: cli.fork_session_id,
fork_show_all: cli.fork_show_all,
model: cli.model,
oss: cli.oss,
oss_provider: cli.oss_provider,
+78 -27
View File
@@ -451,28 +451,79 @@ async fn run_ratatui_app(
initial_config
};
// Determine resume behavior: explicit id, then resume last, then picker.
let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() {
match find_thread_path_by_id_str(&config.codex_home, id_str).await? {
Some(path) => resume_picker::ResumeSelection::Resume(path),
None => {
error!("Error finding conversation path: {id_str}");
restore();
session_log::log_session_end();
let _ = tui.terminal.clear();
if let Err(err) = writeln!(
std::io::stdout(),
"No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions."
) {
error!("Failed to write resume error message: {err}");
}
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: None,
session_lines: Vec::new(),
});
let mut missing_session_exit = |id_str: &str, action: &str| {
error!("Error finding conversation path: {id_str}");
restore();
session_log::log_session_end();
let _ = tui.terminal.clear();
if let Err(err) = writeln!(
std::io::stdout(),
"No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions."
) {
error!("Failed to write session error message: {err}");
}
Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: None,
session_lines: Vec::new(),
})
};
let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some();
let session_selection = if use_fork {
if let Some(id_str) = cli.fork_session_id.as_deref() {
match find_thread_path_by_id_str(&config.codex_home, id_str).await? {
Some(path) => resume_picker::SessionSelection::Fork(path),
None => return missing_session_exit(id_str, "fork"),
}
} else if cli.fork_last {
let provider_filter = vec![config.model_provider_id.clone()];
match RolloutRecorder::list_threads(
&config.codex_home,
1,
None,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
&config.model_provider_id,
)
.await
{
Ok(page) => page
.items
.first()
.map(|it| resume_picker::SessionSelection::Fork(it.path.clone()))
.unwrap_or(resume_picker::SessionSelection::StartFresh),
Err(_) => resume_picker::SessionSelection::StartFresh,
}
} else if cli.fork_picker {
match resume_picker::run_fork_picker(
&mut tui,
&config.codex_home,
&config.model_provider_id,
cli.fork_show_all,
)
.await?
{
resume_picker::SessionSelection::Exit => {
restore();
session_log::log_session_end();
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: None,
session_lines: Vec::new(),
});
}
other => other,
}
} else {
resume_picker::SessionSelection::StartFresh
}
} else if let Some(id_str) = cli.resume_session_id.as_deref() {
match find_thread_path_by_id_str(&config.codex_home, id_str).await? {
Some(path) => resume_picker::SessionSelection::Resume(path),
None => return missing_session_exit(id_str, "resume"),
}
} else if cli.resume_last {
let provider_filter = vec![config.model_provider_id.clone()];
@@ -489,9 +540,9 @@ async fn run_ratatui_app(
Ok(page) => page
.items
.first()
.map(|it| resume_picker::ResumeSelection::Resume(it.path.clone()))
.unwrap_or(resume_picker::ResumeSelection::StartFresh),
Err(_) => resume_picker::ResumeSelection::StartFresh,
.map(|it| resume_picker::SessionSelection::Resume(it.path.clone()))
.unwrap_or(resume_picker::SessionSelection::StartFresh),
Err(_) => resume_picker::SessionSelection::StartFresh,
}
} else if cli.resume_picker {
match resume_picker::run_resume_picker(
@@ -502,7 +553,7 @@ async fn run_ratatui_app(
)
.await?
{
resume_picker::ResumeSelection::Exit => {
resume_picker::SessionSelection::Exit => {
restore();
session_log::log_session_end();
return Ok(AppExitInfo {
@@ -515,7 +566,7 @@ async fn run_ratatui_app(
other => other,
}
} else {
resume_picker::ResumeSelection::StartFresh
resume_picker::SessionSelection::StartFresh
};
let Cli {
@@ -560,7 +611,7 @@ async fn run_ratatui_app(
active_profile,
prompt,
images,
resume_selection,
session_selection,
feedback,
should_show_trust_screen, // Proxy to: is it a first run in this directory?
)
+84 -12
View File
@@ -40,12 +40,42 @@ const PAGE_SIZE: usize = 25;
const LOAD_NEAR_THRESHOLD: usize = 5;
#[derive(Debug, Clone)]
pub enum ResumeSelection {
pub enum SessionSelection {
StartFresh,
Resume(PathBuf),
Fork(PathBuf),
Exit,
}
#[derive(Clone, Copy, Debug)]
pub enum SessionPickerAction {
Resume,
Fork,
}
impl SessionPickerAction {
fn title(self) -> &'static str {
match self {
SessionPickerAction::Resume => "Resume a previous session",
SessionPickerAction::Fork => "Fork a previous session",
}
}
fn action_label(self) -> &'static str {
match self {
SessionPickerAction::Resume => "resume",
SessionPickerAction::Fork => "fork",
}
}
fn selection(self, path: PathBuf) -> SessionSelection {
match self {
SessionPickerAction::Resume => SessionSelection::Resume(path),
SessionPickerAction::Fork => SessionSelection::Fork(path),
}
}
}
#[derive(Clone)]
struct PageLoadRequest {
codex_home: PathBuf,
@@ -73,7 +103,40 @@ pub async fn run_resume_picker(
codex_home: &Path,
default_provider: &str,
show_all: bool,
) -> Result<ResumeSelection> {
) -> Result<SessionSelection> {
run_session_picker(
tui,
codex_home,
default_provider,
show_all,
SessionPickerAction::Resume,
)
.await
}
pub async fn run_fork_picker(
tui: &mut Tui,
codex_home: &Path,
default_provider: &str,
show_all: bool,
) -> Result<SessionSelection> {
run_session_picker(
tui,
codex_home,
default_provider,
show_all,
SessionPickerAction::Fork,
)
.await
}
async fn run_session_picker(
tui: &mut Tui,
codex_home: &Path,
default_provider: &str,
show_all: bool,
action: SessionPickerAction,
) -> Result<SessionSelection> {
let alt = AltScreenGuard::enter(tui);
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
@@ -113,6 +176,7 @@ pub async fn run_resume_picker(
default_provider.clone(),
show_all,
filter_cwd,
action,
);
state.start_initial_load();
state.request_frame();
@@ -151,7 +215,7 @@ pub async fn run_resume_picker(
}
// Fallback – treat as cancel/new
Ok(ResumeSelection::StartFresh)
Ok(SessionSelection::StartFresh)
}
/// RAII guard that ensures we leave the alt-screen on scope exit.
@@ -190,6 +254,7 @@ struct PickerState {
default_provider: String,
show_all: bool,
filter_cwd: Option<PathBuf>,
action: SessionPickerAction,
}
struct PaginationState {
@@ -259,6 +324,7 @@ impl PickerState {
default_provider: String,
show_all: bool,
filter_cwd: Option<PathBuf>,
action: SessionPickerAction,
) -> Self {
Self {
codex_home,
@@ -283,6 +349,7 @@ impl PickerState {
default_provider,
show_all,
filter_cwd,
action,
}
}
@@ -290,19 +357,19 @@ impl PickerState {
self.requester.schedule_frame();
}
async fn handle_key(&mut self, key: KeyEvent) -> Result<Option<ResumeSelection>> {
async fn handle_key(&mut self, key: KeyEvent) -> Result<Option<SessionSelection>> {
match key.code {
KeyCode::Esc => return Ok(Some(ResumeSelection::StartFresh)),
KeyCode::Esc => return Ok(Some(SessionSelection::StartFresh)),
KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
return Ok(Some(ResumeSelection::Exit));
return Ok(Some(SessionSelection::Exit));
}
KeyCode::Enter => {
if let Some(row) = self.filtered_rows.get(self.selected) {
return Ok(Some(ResumeSelection::Resume(row.path.clone())));
return Ok(Some(self.action.selection(row.path.clone())));
}
}
KeyCode::Up => {
@@ -718,10 +785,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
.areas(area);
// Header
frame.render_widget_ref(
Line::from(vec!["Resume a previous session".bold().cyan()]),
header,
);
frame.render_widget_ref(Line::from(vec![state.action.title().bold().cyan()]), header);
// Search line
let q = if state.query.is_empty() {
@@ -738,9 +802,10 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
render_list(frame, list, state, &metrics);
// Hint line
let action_label = state.action.action_label();
let hint_line: Line = vec![
key_hint::plain(KeyCode::Enter).into(),
" to resume ".dim(),
format!(" to {action_label} ").dim(),
" ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" to start new ".dim(),
@@ -1200,6 +1265,7 @@ mod tests {
String::from("openai"),
true,
None,
SessionPickerAction::Resume,
);
let now = Utc::now();
@@ -1349,6 +1415,7 @@ mod tests {
String::from("openai"),
true,
None,
SessionPickerAction::Resume,
);
let page = RolloutRecorder::list_threads(
@@ -1429,6 +1496,7 @@ mod tests {
String::from("openai"),
true,
None,
SessionPickerAction::Resume,
);
state.reset_pagination();
@@ -1497,6 +1565,7 @@ mod tests {
String::from("openai"),
true,
None,
SessionPickerAction::Resume,
);
state.reset_pagination();
state.ingest_page(page(
@@ -1528,6 +1597,7 @@ mod tests {
String::from("openai"),
true,
None,
SessionPickerAction::Resume,
);
let mut items = Vec::new();
@@ -1572,6 +1642,7 @@ mod tests {
String::from("openai"),
true,
None,
SessionPickerAction::Resume,
);
let mut items = Vec::new();
@@ -1616,6 +1687,7 @@ mod tests {
String::from("openai"),
true,
None,
SessionPickerAction::Resume,
);
state.reset_pagination();
state.ingest_page(page(
+3
View File
@@ -20,6 +20,7 @@ pub enum SlashCommand {
Review,
New,
Resume,
Fork,
Init,
Compact,
// Undo,
@@ -45,6 +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::Undo => "ask Codex to undo a turn",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
@@ -72,6 +74,7 @@ impl SlashCommand {
match self {
SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
// | SlashCommand::Undo