mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
898e5f82f0
commit
4223948cf5
+149
-27
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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?
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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?
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user