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
2026-01-12 10:09:11 -08:00
committed by GitHub
Unverified
parent 898e5f82f0
commit 4223948cf5
17 changed files with 754 additions and 124 deletions
+149 -27
View File
@@ -119,6 +119,9 @@ enum Subcommand {
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
Resume(ResumeCommand),
/// Fork a previous interactive session (picker by default; use --last to fork the most recent).
Fork(ForkCommand),
/// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally.
#[clap(name = "cloud", alias = "cloud-tasks")]
Cloud(CloudTasksCli),
@@ -161,6 +164,25 @@ struct ResumeCommand {
config_overrides: TuiCli,
}
#[derive(Debug, Parser)]
struct ForkCommand {
/// Conversation/session id (UUID). When provided, forks this session.
/// If omitted, use --last to pick the most recent recorded session.
#[arg(value_name = "SESSION_ID")]
session_id: Option<String>,
/// Fork the most recent session without showing the picker.
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
last: bool,
/// Show all sessions (disables cwd filtering and shows CWD column).
#[arg(long = "all", default_value_t = false)]
all: bool,
#[clap(flatten)]
config_overrides: TuiCli,
}
#[derive(Debug, Parser)]
struct SandboxArgs {
#[command(subcommand)]
@@ -508,6 +530,23 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
handle_app_exit(exit_info)?;
}
Some(Subcommand::Fork(ForkCommand {
session_id,
last,
all,
config_overrides,
})) => {
interactive = finalize_fork_interactive(
interactive,
root_config_overrides.clone(),
session_id,
last,
all,
config_overrides,
);
let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
handle_app_exit(exit_info)?;
}
Some(Subcommand::Login(mut login_cli)) => {
prepend_config_flags(
&mut login_cli.config_overrides,
@@ -725,7 +764,7 @@ fn finalize_resume_interactive(
interactive.resume_show_all = show_all;
// Merge resume-scoped flags and overrides with highest precedence.
merge_resume_cli_flags(&mut interactive, resume_cli);
merge_interactive_cli_flags(&mut interactive, resume_cli);
// Propagate any root-level config overrides (e.g. `-c key=value`).
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
@@ -733,51 +772,77 @@ fn finalize_resume_interactive(
interactive
}
/// Merge flags provided to `codex resume` so they take precedence over any
/// root-level flags. Only overrides fields explicitly set on the resume-scoped
/// Build the final `TuiCli` for a `codex fork` invocation.
fn finalize_fork_interactive(
mut interactive: TuiCli,
root_config_overrides: CliConfigOverrides,
session_id: Option<String>,
last: bool,
show_all: bool,
fork_cli: TuiCli,
) -> TuiCli {
// Start with the parsed interactive CLI so fork shares the same
// configuration surface area as `codex` without additional flags.
let fork_session_id = session_id;
interactive.fork_picker = fork_session_id.is_none() && !last;
interactive.fork_last = last;
interactive.fork_session_id = fork_session_id;
interactive.fork_show_all = show_all;
// Merge fork-scoped flags and overrides with highest precedence.
merge_interactive_cli_flags(&mut interactive, fork_cli);
// Propagate any root-level config overrides (e.g. `-c key=value`).
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
interactive
}
/// Merge flags provided to `codex resume`/`codex fork` so they take precedence over any
/// root-level flags. Only overrides fields explicitly set on the subcommand-scoped
/// CLI. Also appends `-c key=value` overrides with highest precedence.
fn merge_resume_cli_flags(interactive: &mut TuiCli, resume_cli: TuiCli) {
if let Some(model) = resume_cli.model {
fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) {
if let Some(model) = subcommand_cli.model {
interactive.model = Some(model);
}
if resume_cli.oss {
if subcommand_cli.oss {
interactive.oss = true;
}
if let Some(profile) = resume_cli.config_profile {
if let Some(profile) = subcommand_cli.config_profile {
interactive.config_profile = Some(profile);
}
if let Some(sandbox) = resume_cli.sandbox_mode {
if let Some(sandbox) = subcommand_cli.sandbox_mode {
interactive.sandbox_mode = Some(sandbox);
}
if let Some(approval) = resume_cli.approval_policy {
if let Some(approval) = subcommand_cli.approval_policy {
interactive.approval_policy = Some(approval);
}
if resume_cli.full_auto {
if subcommand_cli.full_auto {
interactive.full_auto = true;
}
if resume_cli.dangerously_bypass_approvals_and_sandbox {
if subcommand_cli.dangerously_bypass_approvals_and_sandbox {
interactive.dangerously_bypass_approvals_and_sandbox = true;
}
if let Some(cwd) = resume_cli.cwd {
if let Some(cwd) = subcommand_cli.cwd {
interactive.cwd = Some(cwd);
}
if resume_cli.web_search {
if subcommand_cli.web_search {
interactive.web_search = true;
}
if !resume_cli.images.is_empty() {
interactive.images = resume_cli.images;
if !subcommand_cli.images.is_empty() {
interactive.images = subcommand_cli.images;
}
if !resume_cli.add_dir.is_empty() {
interactive.add_dir.extend(resume_cli.add_dir);
if !subcommand_cli.add_dir.is_empty() {
interactive.add_dir.extend(subcommand_cli.add_dir);
}
if let Some(prompt) = resume_cli.prompt {
if let Some(prompt) = subcommand_cli.prompt {
interactive.prompt = Some(prompt);
}
interactive
.config_overrides
.raw_overrides
.extend(resume_cli.config_overrides.raw_overrides);
.extend(subcommand_cli.config_overrides.raw_overrides);
}
fn print_completion(cmd: CompletionCommand) {
@@ -794,7 +859,7 @@ mod tests {
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
fn finalize_from_args(args: &[&str]) -> TuiCli {
fn finalize_resume_from_args(args: &[&str]) -> TuiCli {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let MultitoolCli {
interactive,
@@ -823,6 +888,28 @@ mod tests {
)
}
fn finalize_fork_from_args(args: &[&str]) -> TuiCli {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let MultitoolCli {
interactive,
config_overrides: root_overrides,
subcommand,
feature_toggles: _,
} = cli;
let Subcommand::Fork(ForkCommand {
session_id,
last,
all,
config_overrides: fork_cli,
}) = subcommand.expect("fork present")
else {
unreachable!()
};
finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli)
}
fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo {
let token_usage = TokenUsage {
output_tokens: 2,
@@ -871,7 +958,8 @@ mod tests {
#[test]
fn resume_model_flag_applies_when_no_root_flags() {
let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref());
let interactive =
finalize_resume_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref());
assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
assert!(interactive.resume_picker);
@@ -881,7 +969,7 @@ mod tests {
#[test]
fn resume_picker_logic_none_and_not_last() {
let interactive = finalize_from_args(["codex", "resume"].as_ref());
let interactive = finalize_resume_from_args(["codex", "resume"].as_ref());
assert!(interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
@@ -890,7 +978,7 @@ mod tests {
#[test]
fn resume_picker_logic_last() {
let interactive = finalize_from_args(["codex", "resume", "--last"].as_ref());
let interactive = finalize_resume_from_args(["codex", "resume", "--last"].as_ref());
assert!(!interactive.resume_picker);
assert!(interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
@@ -899,7 +987,7 @@ mod tests {
#[test]
fn resume_picker_logic_with_session_id() {
let interactive = finalize_from_args(["codex", "resume", "1234"].as_ref());
let interactive = finalize_resume_from_args(["codex", "resume", "1234"].as_ref());
assert!(!interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id.as_deref(), Some("1234"));
@@ -908,14 +996,14 @@ mod tests {
#[test]
fn resume_all_flag_sets_show_all() {
let interactive = finalize_from_args(["codex", "resume", "--all"].as_ref());
let interactive = finalize_resume_from_args(["codex", "resume", "--all"].as_ref());
assert!(interactive.resume_picker);
assert!(interactive.resume_show_all);
}
#[test]
fn resume_merges_option_flags_and_full_auto() {
let interactive = finalize_from_args(
let interactive = finalize_resume_from_args(
[
"codex",
"resume",
@@ -972,7 +1060,7 @@ mod tests {
#[test]
fn resume_merges_dangerously_bypass_flag() {
let interactive = finalize_from_args(
let interactive = finalize_resume_from_args(
[
"codex",
"resume",
@@ -986,6 +1074,40 @@ mod tests {
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn fork_picker_logic_none_and_not_last() {
let interactive = finalize_fork_from_args(["codex", "fork"].as_ref());
assert!(interactive.fork_picker);
assert!(!interactive.fork_last);
assert_eq!(interactive.fork_session_id, None);
assert!(!interactive.fork_show_all);
}
#[test]
fn fork_picker_logic_last() {
let interactive = finalize_fork_from_args(["codex", "fork", "--last"].as_ref());
assert!(!interactive.fork_picker);
assert!(interactive.fork_last);
assert_eq!(interactive.fork_session_id, None);
assert!(!interactive.fork_show_all);
}
#[test]
fn fork_picker_logic_with_session_id() {
let interactive = finalize_fork_from_args(["codex", "fork", "1234"].as_ref());
assert!(!interactive.fork_picker);
assert!(!interactive.fork_last);
assert_eq!(interactive.fork_session_id.as_deref(), Some("1234"));
assert!(!interactive.fork_show_all);
}
#[test]
fn fork_all_flag_sets_show_all() {
let interactive = finalize_fork_from_args(["codex", "fork", "--all"].as_ref());
assert!(interactive.fork_picker);
assert!(interactive.fork_show_all);
}
#[test]
fn feature_toggles_known_features_generate_overrides() {
let toggles = FeatureToggles {
+105 -10
View File
@@ -20,7 +20,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::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
@@ -340,7 +340,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> {
@@ -373,8 +373,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(),
@@ -390,12 +390,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(),
@@ -412,6 +413,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();
@@ -592,7 +616,7 @@ impl App {
)
.await?
{
ResumeSelection::Resume(path) => {
SessionSelection::Resume(path) => {
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
@@ -641,14 +665,85 @@ 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.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: 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);
}
}
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
@@ -39,6 +39,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
@@ -1713,6 +1713,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
@@ -1476,6 +1476,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;
+17
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>,
+76 -26
View File
@@ -431,27 +431,77 @@ 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(),
thread_id: None,
update_action: None,
});
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(),
thread_id: None,
update_action: None,
})
};
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(),
thread_id: None,
update_action: None,
});
}
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()];
@@ -468,9 +518,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(
@@ -481,7 +531,7 @@ async fn run_ratatui_app(
)
.await?
{
resume_picker::ResumeSelection::Exit => {
resume_picker::SessionSelection::Exit => {
restore();
session_log::log_session_end();
return Ok(AppExitInfo {
@@ -493,7 +543,7 @@ async fn run_ratatui_app(
other => other,
}
} else {
resume_picker::ResumeSelection::StartFresh
resume_picker::SessionSelection::StartFresh
};
let Cli {
@@ -513,7 +563,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
@@ -21,6 +21,7 @@ pub enum SlashCommand {
Review,
New,
Resume,
Fork,
Init,
Compact,
// Undo,
@@ -47,6 +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::Undo => "ask Codex to undo a turn",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
@@ -76,6 +78,7 @@ impl SlashCommand {
match self {
SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
// | SlashCommand::Undo
+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