Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 149 additions & 27 deletions codex-rs/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,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),
Expand Down Expand Up @@ -159,6 +162,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")]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really want this? How is the last session defined? Last one closed? Last turn started? Last turn finished?
I guess this will create more confusion

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It basically mirrors resume and use same logic.

/// Load thread file paths from disk using directory traversal.
///
/// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`
/// Returned newest (latest) first.

we will need to clean up it at some point or define better.

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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is cool but it seems to add quite some complexity for low value

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, I followed resume here.

}

#[derive(Debug, Parser)]
struct SandboxArgs {
#[command(subcommand)]
Expand Down Expand Up @@ -514,6 +536,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,
Expand Down Expand Up @@ -724,59 +763,85 @@ 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);

interactive
}

/// 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` so they take precedence over any
/// root-level flags. Only overrides fields explicitly set on the resume-scoped
/// 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) {
Expand All @@ -793,7 +858,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,
Expand Down Expand Up @@ -822,6 +887,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,
Expand Down Expand Up @@ -870,7 +957,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);
Expand All @@ -880,7 +968,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);
Expand All @@ -889,7 +977,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);
Expand All @@ -898,7 +986,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"));
Expand All @@ -907,14 +995,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",
Expand Down Expand Up @@ -971,7 +1059,7 @@ mod tests {

#[test]
fn resume_merges_dangerously_bypass_flag() {
let interactive = finalize_from_args(
let interactive = finalize_resume_from_args(
[
"codex",
"resume",
Expand All @@ -985,6 +1073,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 {
Expand Down
Loading
Loading