mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Prompt to unarchive sessions before resuming or forking (#39640)
## Why Starting an archived session with `codex resume` or `codex fork` stopped with guidance to run a separate `codex unarchive` command first. ## What changed - Detect archived-session startup failures and offer to unarchive and retry the requested operation. - Allow the user to cancel without modifying the archived session. - Render the confirmation in the alternate screen when available and preserve inline terminal context when `--no-alt-screen` is set. - Preserve `--no-alt-screen` whether it appears before or after the `resume` or `fork` subcommand. ## Testing - Cover confirmation, cancellation, retry behavior, and unrelated startup failures for both resume and fork. - Add prompt interaction and terminal rendering coverage, including narrow and inline viewports. GitOrigin-RevId: 382cdbd15b5d12c091e554d6d3a2f9b3589654f4
This commit is contained in:
1 parent
fdc23b93b8
commit
d944ce83a2
11 files changed
+736
-57
No files matched your search
@@ -2795,6 +2795,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli)
|
|||||||
strict_config,
|
strict_config,
|
||||||
approval_policy,
|
approval_policy,
|
||||||
web_search,
|
web_search,
|
||||||
|
no_alt_screen,
|
||||||
prompt,
|
prompt,
|
||||||
mut config_overrides,
|
mut config_overrides,
|
||||||
..
|
..
|
||||||
@@ -2814,6 +2815,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli)
|
|||||||
if web_search {
|
if web_search {
|
||||||
interactive.web_search = true;
|
interactive.web_search = true;
|
||||||
}
|
}
|
||||||
|
interactive.no_alt_screen |= no_alt_screen;
|
||||||
if strict_config {
|
if strict_config {
|
||||||
interactive.strict_config = true;
|
interactive.strict_config = true;
|
||||||
}
|
}
|
||||||
@@ -3772,6 +3774,18 @@ mod tests {
|
|||||||
assert_eq!(interactive.resume_session_id, None);
|
assert_eq!(interactive.resume_session_id, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resume_and_fork_preserve_no_alt_screen() {
|
||||||
|
for (command, finalize) in [
|
||||||
|
("resume", finalize_resume_from_args as fn(&[&str]) -> TuiCli),
|
||||||
|
("fork", finalize_fork_from_args as fn(&[&str]) -> TuiCli),
|
||||||
|
] {
|
||||||
|
assert!(finalize(&["codex", command, "--no-alt-screen"]).no_alt_screen);
|
||||||
|
assert!(finalize(&["codex", "--no-alt-screen", command]).no_alt_screen);
|
||||||
|
assert!(!finalize(&["codex", command]).no_alt_screen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resume_picker_logic_none_and_not_last() {
|
fn resume_picker_logic_none_and_not_last() {
|
||||||
let interactive = finalize_resume_from_args(["codex", "resume"].as_ref());
|
let interactive = finalize_resume_from_args(["codex", "resume"].as_ref());
|
||||||
|
|||||||
@@ -682,31 +682,6 @@ fn active_turn_steer_race(error: &TypedRequestError) -> Option<ActiveTurnSteerRa
|
|||||||
Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id })
|
Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_start_error(
|
|
||||||
action: &str,
|
|
||||||
target_session: &SessionTarget,
|
|
||||||
err: color_eyre::eyre::Report,
|
|
||||||
) -> color_eyre::eyre::Report {
|
|
||||||
if let Some(message) = archived_session_guidance(&err) {
|
|
||||||
return color_eyre::eyre::eyre!("{message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_label = target_session.display_label();
|
|
||||||
color_eyre::eyre::eyre!("Failed to {action} session from {target_label}: {err}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn archived_session_guidance(err: &color_eyre::eyre::Report) -> Option<String> {
|
|
||||||
let err = err.to_string();
|
|
||||||
let message = &err[err.find("session ")?..];
|
|
||||||
if !message.contains(" is archived. Run `codex unarchive ") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let message = message
|
|
||||||
.split_once(" (code ")
|
|
||||||
.map_or(message, |(message, _)| message);
|
|
||||||
Some(message.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn active_turn_interrupt_race(error: &TypedRequestError) -> Option<String> {
|
fn active_turn_interrupt_race(error: &TypedRequestError) -> Option<String> {
|
||||||
let TypedRequestError::Server { method, source } = error else {
|
let TypedRequestError::Server { method, source } = error else {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
//! remains isolated from protected interactive requests until the initialized composer owns it.
|
//! remains isolated from protected interactive requests until the initialized composer owns it.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::session_start::SessionStartAction;
|
||||||
|
use crate::session_start::cancel_session_start;
|
||||||
|
use crate::session_start::complete_session_start;
|
||||||
|
use crate::unarchive_prompt::run_unarchive_prompt;
|
||||||
|
|
||||||
async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) -> Option<String> {
|
async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) -> Option<String> {
|
||||||
let provider = create_model_provider(provider.clone(), /*auth_manager*/ None);
|
let provider = create_model_provider(provider.clone(), /*auth_manager*/ None);
|
||||||
@@ -304,10 +308,25 @@ impl App {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resumed) => resumed
|
Ok(resumed) => resumed,
|
||||||
.map_err(|err| session_start_error("resume", &target_session, err))?,
|
|
||||||
Err(err) => return shutdown_on_startup_error(app_server, err).await,
|
Err(err) => return shutdown_on_startup_error(app_server, err).await,
|
||||||
};
|
};
|
||||||
|
let action = SessionStartAction::Resume(model_settings);
|
||||||
|
let Some(resumed) = complete_session_start(
|
||||||
|
&mut app_server,
|
||||||
|
&config,
|
||||||
|
&target_session,
|
||||||
|
action,
|
||||||
|
resumed,
|
||||||
|
async || {
|
||||||
|
startup_draft.flush_pending_events(tui).await?;
|
||||||
|
run_unarchive_prompt(tui, target_session.thread_id, action).await
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(cancel_session_start(app_server).await);
|
||||||
|
};
|
||||||
let init = crate::chatwidget::ChatWidgetInit {
|
let init = crate::chatwidget::ChatWidgetInit {
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
frame_requester: tui.frame_requester(),
|
frame_requester: tui.frame_requester(),
|
||||||
@@ -350,11 +369,25 @@ impl App {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(forked) => {
|
Ok(forked) => forked,
|
||||||
forked.map_err(|err| session_start_error("fork", &target_session, err))?
|
|
||||||
}
|
|
||||||
Err(err) => return shutdown_on_startup_error(app_server, err).await,
|
Err(err) => return shutdown_on_startup_error(app_server, err).await,
|
||||||
};
|
};
|
||||||
|
let action = SessionStartAction::Fork;
|
||||||
|
let Some(forked) = complete_session_start(
|
||||||
|
&mut app_server,
|
||||||
|
&config,
|
||||||
|
&target_session,
|
||||||
|
action,
|
||||||
|
forked,
|
||||||
|
async || {
|
||||||
|
startup_draft.flush_pending_events(tui).await?;
|
||||||
|
run_unarchive_prompt(tui, target_session.thread_id, action).await
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(cancel_session_start(app_server).await);
|
||||||
|
};
|
||||||
let init = crate::chatwidget::ChatWidgetInit {
|
let init = crate::chatwidget::ChatWidgetInit {
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
frame_requester: tui.frame_requester(),
|
frame_requester: tui.frame_requester(),
|
||||||
|
|||||||
@@ -6272,33 +6272,6 @@ fn active_turn_not_steerable_turn_error_extracts_structured_server_error() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_start_error_surfaces_archived_guidance_without_rollout_path() {
|
|
||||||
let thread_id =
|
|
||||||
ThreadId::from_string("019e72f4-e09a-70f2-b2c2-a153a57b8cc0").expect("thread id");
|
|
||||||
let target_session = SessionTarget {
|
|
||||||
path: Some(std::path::PathBuf::from(
|
|
||||||
"/Users/me/.codex/archived_sessions/rollout.jsonl",
|
|
||||||
)),
|
|
||||||
thread_id,
|
|
||||||
history_mode: None,
|
|
||||||
};
|
|
||||||
let expected = format!(
|
|
||||||
"session {thread_id} is archived. Run `codex unarchive {thread_id}` to unarchive it first."
|
|
||||||
);
|
|
||||||
|
|
||||||
for action in ["resume", "fork"] {
|
|
||||||
let err = color_eyre::eyre::eyre!(
|
|
||||||
"thread/{action} failed during TUI bootstrap: thread/{action} failed: {expected} (code -32600)"
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
session_start_error(action, &target_session, err).to_string(),
|
|
||||||
expected
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn active_turn_steer_race_detects_missing_active_turn() {
|
fn active_turn_steer_race_detects_missing_active_turn() {
|
||||||
let error = TypedRequestError::Server {
|
let error = TypedRequestError::Server {
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ mod session_archive_commands;
|
|||||||
mod session_log;
|
mod session_log;
|
||||||
mod session_queue_commands;
|
mod session_queue_commands;
|
||||||
mod session_resume;
|
mod session_resume;
|
||||||
|
mod session_start;
|
||||||
mod session_state;
|
mod session_state;
|
||||||
mod skills_helpers;
|
mod skills_helpers;
|
||||||
mod slash_command;
|
mod slash_command;
|
||||||
@@ -195,6 +196,7 @@ mod tooltips;
|
|||||||
mod transcript_reflow;
|
mod transcript_reflow;
|
||||||
mod tui;
|
mod tui;
|
||||||
mod ui_consts;
|
mod ui_consts;
|
||||||
|
mod unarchive_prompt;
|
||||||
pub(crate) mod update_action;
|
pub(crate) mod update_action;
|
||||||
pub use update_action::UpdateAction;
|
pub use update_action::UpdateAction;
|
||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! Interactive recovery when starting an archived session.
|
||||||
|
|
||||||
|
use crate::app::AppExitInfo;
|
||||||
|
use crate::app::ExitReason;
|
||||||
|
use crate::app_server_session::AppServerSession;
|
||||||
|
use crate::app_server_session::AppServerStartedThread;
|
||||||
|
use crate::app_server_session::ResumeModelSettings;
|
||||||
|
use crate::legacy_core::config::Config;
|
||||||
|
use crate::resume_picker::SessionTarget;
|
||||||
|
use crate::unarchive_prompt::UnarchiveChoice;
|
||||||
|
use color_eyre::Result;
|
||||||
|
use color_eyre::eyre::WrapErr;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) enum SessionStartAction {
|
||||||
|
Resume(ResumeModelSettings),
|
||||||
|
Fork,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStartAction {
|
||||||
|
pub(crate) fn verb(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Resume(_) => "resume",
|
||||||
|
Self::Fork => "fork",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(
|
||||||
|
self,
|
||||||
|
app_server: &mut AppServerSession,
|
||||||
|
config: &Config,
|
||||||
|
target: &SessionTarget,
|
||||||
|
) -> Result<AppServerStartedThread> {
|
||||||
|
match self {
|
||||||
|
Self::Resume(settings) => {
|
||||||
|
app_server
|
||||||
|
.resume_thread(config.clone(), target.thread_id, settings)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Self::Fork => {
|
||||||
|
app_server
|
||||||
|
.fork_thread(config.clone(), target.thread_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn complete_session_start(
|
||||||
|
app_server: &mut AppServerSession,
|
||||||
|
config: &Config,
|
||||||
|
target: &SessionTarget,
|
||||||
|
action: SessionStartAction,
|
||||||
|
initial_result: Result<AppServerStartedThread>,
|
||||||
|
confirm: impl AsyncFnOnce() -> Result<UnarchiveChoice>,
|
||||||
|
) -> Result<Option<AppServerStartedThread>> {
|
||||||
|
match initial_result {
|
||||||
|
Ok(started) => return Ok(Some(started)),
|
||||||
|
Err(err) => {
|
||||||
|
// Match the requested ID as well as the server's archive guidance: an unrelated
|
||||||
|
// startup failure must never cause us to unarchive a session.
|
||||||
|
let archived_prefix = format!("session {} is archived. ", target.thread_id);
|
||||||
|
if !archived_session_guidance(&err)
|
||||||
|
.is_some_and(|message| message.starts_with(&archived_prefix))
|
||||||
|
{
|
||||||
|
return Err(session_start_error(action.verb(), target, err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if confirm().await? == UnarchiveChoice::Cancel {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
app_server
|
||||||
|
.thread_unarchive(target.thread_id)
|
||||||
|
.await
|
||||||
|
.wrap_err_with(|| format!("Failed to unarchive session {}", target.thread_id))?;
|
||||||
|
// Retry by ID, not by the old rollout path, which unarchiving may have moved.
|
||||||
|
action
|
||||||
|
.start(app_server, config, target)
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|err| session_start_error(action.verb(), target, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cancel_session_start(app_server: AppServerSession) -> AppExitInfo {
|
||||||
|
if let Err(err) = app_server.shutdown().await {
|
||||||
|
tracing::warn!("app-server shutdown failed: {err}");
|
||||||
|
}
|
||||||
|
AppExitInfo {
|
||||||
|
token_usage: Default::default(),
|
||||||
|
thread_id: None,
|
||||||
|
resume_hint: None,
|
||||||
|
update_action: None,
|
||||||
|
exit_reason: ExitReason::UserRequested,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_start_error(
|
||||||
|
action: &str,
|
||||||
|
target_session: &SessionTarget,
|
||||||
|
err: color_eyre::Report,
|
||||||
|
) -> color_eyre::Report {
|
||||||
|
if let Some(message) = archived_session_guidance(&err) {
|
||||||
|
return color_eyre::eyre::eyre!("{message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_label = target_session.display_label();
|
||||||
|
color_eyre::eyre::eyre!("Failed to {action} session from {target_label}: {err}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn archived_session_guidance(err: &color_eyre::Report) -> Option<String> {
|
||||||
|
let err = err.to_string();
|
||||||
|
let message = &err[err.find("session ")?..];
|
||||||
|
if !message.contains(" is archived. Run `codex unarchive ") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let message = message
|
||||||
|
.split_once(" (code ")
|
||||||
|
.map_or(message, |(message, _)| message);
|
||||||
|
Some(message.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "session_start_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::legacy_core::config::ConfigBuilder;
|
||||||
|
use app_test_support::create_fake_rollout;
|
||||||
|
use app_test_support::rollout_path;
|
||||||
|
use codex_config::LoaderOverrides;
|
||||||
|
use codex_protocol::ThreadId;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
async fn start_session(
|
||||||
|
app_server: &mut AppServerSession,
|
||||||
|
config: &Config,
|
||||||
|
target: &SessionTarget,
|
||||||
|
action: SessionStartAction,
|
||||||
|
confirm: impl AsyncFnOnce() -> Result<UnarchiveChoice>,
|
||||||
|
) -> Result<Option<AppServerStartedThread>> {
|
||||||
|
let initial_result = action.start(app_server, config, target).await;
|
||||||
|
complete_session_start(app_server, config, target, action, initial_result, confirm).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn archived_session_requires_confirmation_before_resume_or_fork() -> Result<()> {
|
||||||
|
for action in [
|
||||||
|
SessionStartAction::Resume(ResumeModelSettings::RestoreFromThread),
|
||||||
|
SessionStartAction::Fork,
|
||||||
|
] {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
let config = ConfigBuilder::default()
|
||||||
|
.codex_home(codex_home.path().to_path_buf())
|
||||||
|
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
let timestamp = "2025-01-05T12-00-00";
|
||||||
|
let id = create_fake_rollout(
|
||||||
|
codex_home.path(),
|
||||||
|
timestamp,
|
||||||
|
"2025-01-05T12:00:00Z",
|
||||||
|
"Archived saved user message",
|
||||||
|
Some(config.model_provider_id.as_str()),
|
||||||
|
/*git_info*/ None,
|
||||||
|
)
|
||||||
|
.expect("create source rollout");
|
||||||
|
let active_path = rollout_path(codex_home.path(), timestamp, &id);
|
||||||
|
let archived_dir = codex_home.path().join("archived_sessions");
|
||||||
|
std::fs::create_dir_all(&archived_dir)?;
|
||||||
|
let archived_path = archived_dir.join(active_path.file_name().unwrap());
|
||||||
|
std::fs::rename(&active_path, &archived_path)?;
|
||||||
|
let target = SessionTarget {
|
||||||
|
path: Some(archived_path.clone()),
|
||||||
|
thread_id: ThreadId::from_string(&id)?,
|
||||||
|
history_mode: None,
|
||||||
|
};
|
||||||
|
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
|
||||||
|
let prompts = Cell::new(0);
|
||||||
|
|
||||||
|
let cancelled = start_session(&mut app_server, &config, &target, action, async || {
|
||||||
|
prompts.set(prompts.get() + 1);
|
||||||
|
Ok(UnarchiveChoice::Cancel)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert!(cancelled.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
(prompts.get(), archived_path.exists(), active_path.exists()),
|
||||||
|
(1, true, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
let started = start_session(&mut app_server, &config, &target, action, async || {
|
||||||
|
prompts.set(prompts.get() + 1);
|
||||||
|
Ok(UnarchiveChoice::Unarchive)
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
.expect("confirmed session should start");
|
||||||
|
assert_eq!(
|
||||||
|
(prompts.get(), archived_path.exists(), active_path.exists()),
|
||||||
|
(2, false, true)
|
||||||
|
);
|
||||||
|
match action {
|
||||||
|
SessionStartAction::Resume(_) => {
|
||||||
|
assert_eq!(started.session.thread_id, target.thread_id)
|
||||||
|
}
|
||||||
|
SessionStartAction::Fork => {
|
||||||
|
assert_ne!(started.session.thread_id, target.thread_id);
|
||||||
|
assert_eq!(
|
||||||
|
app_server
|
||||||
|
.thread_read(started.session.thread_id, /*include_turns*/ false)
|
||||||
|
.await?
|
||||||
|
.forked_from_id,
|
||||||
|
Some(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resumed = start_session(
|
||||||
|
&mut app_server,
|
||||||
|
&config,
|
||||||
|
&target,
|
||||||
|
SessionStartAction::Resume(ResumeModelSettings::RestoreFromThread),
|
||||||
|
async || panic!("active sessions must not prompt"),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.expect("active session should start");
|
||||||
|
assert_eq!(resumed.session.thread_id, target.thread_id);
|
||||||
|
|
||||||
|
let missing = SessionTarget {
|
||||||
|
path: None,
|
||||||
|
thread_id: ThreadId::new(),
|
||||||
|
history_mode: None,
|
||||||
|
};
|
||||||
|
let error = start_session(&mut app_server, &config, &missing, action, async || {
|
||||||
|
panic!("unrelated failures must not prompt")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("missing session should fail");
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.starts_with(&format!("Failed to {} session", action.verb()))
|
||||||
|
);
|
||||||
|
app_server.shutdown().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_start_error_surfaces_archived_guidance_without_rollout_path() {
|
||||||
|
let thread_id =
|
||||||
|
ThreadId::from_string("019e72f4-e09a-70f2-b2c2-a153a57b8cc0").expect("thread id");
|
||||||
|
let target_session = SessionTarget {
|
||||||
|
path: Some(std::path::PathBuf::from(
|
||||||
|
"/Users/me/.codex/archived_sessions/rollout.jsonl",
|
||||||
|
)),
|
||||||
|
thread_id,
|
||||||
|
history_mode: None,
|
||||||
|
};
|
||||||
|
let expected = format!(
|
||||||
|
"session {thread_id} is archived. Run `codex unarchive {thread_id}` to unarchive it first."
|
||||||
|
);
|
||||||
|
|
||||||
|
for action in ["resume", "fork"] {
|
||||||
|
let err = color_eyre::eyre::eyre!(
|
||||||
|
"thread/{action} failed during TUI bootstrap: thread/{action} failed: {expected} (code -32600)"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session_start_error(action, &target_session, err).to_string(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/unarchive_prompt_tests.rs
|
||||||
|
assertion_line: 108
|
||||||
|
expression: render_inline(80)
|
||||||
|
---
|
||||||
|
$ codex fork saved-session
|
||||||
|
|
||||||
|
|
||||||
|
This conversation is archived
|
||||||
|
019e72f4-e09a-70f2-b2c2-a153a57b8cc0
|
||||||
|
|
||||||
|
› 1. Unarchive and fork
|
||||||
|
2. Cancel
|
||||||
|
|
||||||
|
Press enter to continue or esc to cancel
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
source: tui/src/unarchive_prompt_tests.rs
|
||||||
|
assertion_line: 113
|
||||||
|
expression: render_inline(28)
|
||||||
|
---
|
||||||
|
$ codex fork saved-session
|
||||||
|
|
||||||
|
|
||||||
|
This conversation is
|
||||||
|
archived
|
||||||
|
019e72f4-e09a-70f2-b2c2-a153
|
||||||
|
a57b8cc0
|
||||||
|
|
||||||
|
› 1. Unarchive and fork
|
||||||
|
2. Cancel
|
||||||
|
|
||||||
|
Press enter to continue or
|
||||||
|
esc to cancel
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
use crate::key_hint;
|
||||||
|
use crate::render::renderable::ColumnRenderable;
|
||||||
|
use crate::render::renderable::Renderable;
|
||||||
|
use crate::selection_list::selection_option_row;
|
||||||
|
use crate::session_start::SessionStartAction;
|
||||||
|
use crate::tui::Tui;
|
||||||
|
use crate::tui::TuiEvent;
|
||||||
|
use codex_protocol::ThreadId;
|
||||||
|
use color_eyre::Result;
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use crossterm::event::KeyEventKind;
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Stylize as _;
|
||||||
|
use ratatui::text::Line;
|
||||||
|
use ratatui::widgets::Clear;
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::widgets::Widget;
|
||||||
|
use ratatui::widgets::WidgetRef;
|
||||||
|
use ratatui::widgets::Wrap;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum UnarchiveChoice {
|
||||||
|
Unarchive,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn run_unarchive_prompt(
|
||||||
|
tui: &mut Tui,
|
||||||
|
thread_id: ThreadId,
|
||||||
|
action: SessionStartAction,
|
||||||
|
) -> Result<UnarchiveChoice> {
|
||||||
|
let mut guard = PromptScreenGuard::enter(tui)?;
|
||||||
|
let mut screen = UnarchivePrompt::new(thread_id, action);
|
||||||
|
guard.draw(&screen)?;
|
||||||
|
guard
|
||||||
|
.tui
|
||||||
|
.discard_pending_input_before_interactive_screen()?;
|
||||||
|
let events = guard.tui.event_stream();
|
||||||
|
tokio::pin!(events);
|
||||||
|
loop {
|
||||||
|
guard.draw(&screen)?;
|
||||||
|
let Some(event) = events.next().await else {
|
||||||
|
return Ok(UnarchiveChoice::Cancel);
|
||||||
|
};
|
||||||
|
guard.tui.screen_size_for_event(&event)?;
|
||||||
|
match event {
|
||||||
|
TuiEvent::Key(key) => {
|
||||||
|
if let Some(choice) = screen.handle_key(key) {
|
||||||
|
return Ok(choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TuiEvent::Paste(_) | TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep startup prompts out of normal scrollback when an alternate screen is available.
|
||||||
|
/// Inline mode owns only its content-height viewport, including when the terminal is resized.
|
||||||
|
struct PromptScreenGuard<'a> {
|
||||||
|
tui: &'a mut Tui,
|
||||||
|
entered_alt_screen: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> PromptScreenGuard<'a> {
|
||||||
|
fn enter(tui: &'a mut Tui) -> Result<Self> {
|
||||||
|
let was_alternate = tui.is_alt_screen_active();
|
||||||
|
if !was_alternate {
|
||||||
|
tui.enter_alt_screen()?;
|
||||||
|
}
|
||||||
|
let entered_alt_screen = !was_alternate && tui.is_alt_screen_active();
|
||||||
|
Ok(Self {
|
||||||
|
tui,
|
||||||
|
entered_alt_screen,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&mut self, screen: &UnarchivePrompt) -> Result<()> {
|
||||||
|
let height = if self.tui.is_alt_screen_active() {
|
||||||
|
u16::MAX
|
||||||
|
} else {
|
||||||
|
screen
|
||||||
|
.content()
|
||||||
|
.desired_height(self.tui.terminal.size()?.width)
|
||||||
|
};
|
||||||
|
self.tui.draw(height, |frame| {
|
||||||
|
frame.render_widget_ref(screen, frame.area());
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PromptScreenGuard<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let result = if self.entered_alt_screen {
|
||||||
|
self.tui.leave_alt_screen()
|
||||||
|
} else if !self.tui.is_alt_screen_active() {
|
||||||
|
let result = self.tui.terminal.clear();
|
||||||
|
let mut area = self.tui.terminal.viewport_area;
|
||||||
|
area.height = 0;
|
||||||
|
self.tui.terminal.set_viewport_area(area);
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
if let Err(err) = result {
|
||||||
|
tracing::warn!("failed to restore terminal after unarchive prompt: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UnarchivePrompt {
|
||||||
|
thread_id: ThreadId,
|
||||||
|
action: SessionStartAction,
|
||||||
|
highlighted: UnarchiveChoice,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnarchivePrompt {
|
||||||
|
fn new(thread_id: ThreadId, action: SessionStartAction) -> Self {
|
||||||
|
Self {
|
||||||
|
thread_id,
|
||||||
|
action,
|
||||||
|
highlighted: UnarchiveChoice::Unarchive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&mut self, key: KeyEvent) -> Option<UnarchiveChoice> {
|
||||||
|
if key.kind == KeyEventKind::Release {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d'))
|
||||||
|
{
|
||||||
|
return Some(UnarchiveChoice::Cancel);
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Down | KeyCode::Char('k' | 'j') => {
|
||||||
|
self.highlighted = match self.highlighted {
|
||||||
|
UnarchiveChoice::Unarchive => UnarchiveChoice::Cancel,
|
||||||
|
UnarchiveChoice::Cancel => UnarchiveChoice::Unarchive,
|
||||||
|
};
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Char('1' | 'y' | 'Y') => Some(UnarchiveChoice::Unarchive),
|
||||||
|
KeyCode::Esc | KeyCode::Char('2' | 'n' | 'N') => Some(UnarchiveChoice::Cancel),
|
||||||
|
KeyCode::Enter => Some(self.highlighted),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content(&self) -> ColumnRenderable<'static> {
|
||||||
|
let mut column = ColumnRenderable::new();
|
||||||
|
column.push("");
|
||||||
|
column.push(
|
||||||
|
Paragraph::new("This conversation is archived".bold()).wrap(Wrap { trim: false }),
|
||||||
|
);
|
||||||
|
column.push(
|
||||||
|
Paragraph::new(Line::from(self.thread_id.to_string()).dim()).wrap(Wrap { trim: false }),
|
||||||
|
);
|
||||||
|
column.push("");
|
||||||
|
column.push(selection_option_row(
|
||||||
|
/*index*/ 0,
|
||||||
|
format!("Unarchive and {}", self.action.verb()),
|
||||||
|
self.highlighted == UnarchiveChoice::Unarchive,
|
||||||
|
));
|
||||||
|
column.push(selection_option_row(
|
||||||
|
/*index*/ 1,
|
||||||
|
"Cancel".to_string(),
|
||||||
|
self.highlighted == UnarchiveChoice::Cancel,
|
||||||
|
));
|
||||||
|
column.push("");
|
||||||
|
column.push(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
"Press ".dim(),
|
||||||
|
key_hint::plain(KeyCode::Enter).into(),
|
||||||
|
" to continue or ".dim(),
|
||||||
|
key_hint::plain(KeyCode::Esc).into(),
|
||||||
|
" to cancel".dim(),
|
||||||
|
]))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
);
|
||||||
|
column
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetRef for &UnarchivePrompt {
|
||||||
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
Clear.render(area, buf);
|
||||||
|
self.content().render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "unarchive_prompt_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::app_server_session::ResumeModelSettings;
|
||||||
|
use crate::test_backend::VT100Backend;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::widgets::FrameExt as _;
|
||||||
|
|
||||||
|
fn prompt(action: SessionStartAction) -> UnarchivePrompt {
|
||||||
|
UnarchivePrompt::new(
|
||||||
|
ThreadId::from_string("019e72f4-e09a-70f2-b2c2-a153a57b8cc0").unwrap(),
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(screen: &UnarchivePrompt) -> String {
|
||||||
|
let mut terminal = Terminal::new(VT100Backend::new(/*width*/ 80, /*height*/ 10)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| frame.render_widget_ref(screen, frame.area()))
|
||||||
|
.unwrap();
|
||||||
|
terminal
|
||||||
|
.backend()
|
||||||
|
.to_string()
|
||||||
|
.lines()
|
||||||
|
.map(str::trim_end)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resume_prompt_snapshot() {
|
||||||
|
let screen = prompt(SessionStartAction::Resume(
|
||||||
|
ResumeModelSettings::RestoreFromThread,
|
||||||
|
));
|
||||||
|
insta::assert_snapshot!(render(&screen), @"
|
||||||
|
This conversation is archived
|
||||||
|
019e72f4-e09a-70f2-b2c2-a153a57b8cc0
|
||||||
|
|
||||||
|
› 1. Unarchive and resume
|
||||||
|
2. Cancel
|
||||||
|
|
||||||
|
Press enter to continue or esc to cancel
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fork_prompt_cancel_snapshot() {
|
||||||
|
let mut screen = prompt(SessionStartAction::Fork);
|
||||||
|
assert_eq!(
|
||||||
|
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
insta::assert_snapshot!(render(&screen), @"
|
||||||
|
This conversation is archived
|
||||||
|
019e72f4-e09a-70f2-b2c2-a153a57b8cc0
|
||||||
|
|
||||||
|
1. Unarchive and fork
|
||||||
|
› 2. Cancel
|
||||||
|
|
||||||
|
Press enter to continue or esc to cancel
|
||||||
|
");
|
||||||
|
assert_eq!(
|
||||||
|
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||||
|
Some(UnarchiveChoice::Cancel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirmation_requires_an_accept_key() {
|
||||||
|
let mut screen = prompt(SessionStartAction::Fork);
|
||||||
|
for key in [
|
||||||
|
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||||
|
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
|
||||||
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||||
|
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
|
||||||
|
] {
|
||||||
|
assert_eq!(screen.handle_key(key), Some(UnarchiveChoice::Cancel));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
screen.handle_key(KeyEvent::new_with_kind(
|
||||||
|
KeyCode::Enter,
|
||||||
|
KeyModifiers::NONE,
|
||||||
|
KeyEventKind::Release
|
||||||
|
)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||||
|
Some(UnarchiveChoice::Unarchive)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_inline(width: u16) -> String {
|
||||||
|
let screen = prompt(SessionStartAction::Fork);
|
||||||
|
let height = screen.content().desired_height(width);
|
||||||
|
let mut terminal = Terminal::new(VT100Backend::new(width, height + 2)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("$ codex fork saved-session"),
|
||||||
|
Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1),
|
||||||
|
);
|
||||||
|
frame.render_widget_ref(&screen, Rect::new(/*x*/ 0, /*y*/ 2, width, height));
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
terminal
|
||||||
|
.backend()
|
||||||
|
.to_string()
|
||||||
|
.lines()
|
||||||
|
.map(str::trim_end)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
.trim_end()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_prompt_preserves_shell_context_snapshot() {
|
||||||
|
insta::assert_snapshot!("inline_prompt", render_inline(/*width*/ 80));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn narrow_inline_prompt_wraps_snapshot() {
|
||||||
|
insta::assert_snapshot!("narrow_inline_prompt", render_inline(/*width*/ 28));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn inline_prompt_only_claims_content_height() -> Result<()> {
|
||||||
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||||
|
tui.set_alt_screen_enabled(/*enabled*/ false);
|
||||||
|
let original = Rect::new(
|
||||||
|
/*x*/ 0, /*y*/ 3, /*width*/ 80, /*height*/ 0,
|
||||||
|
);
|
||||||
|
tui.terminal.set_viewport_area(original);
|
||||||
|
let screen = prompt(SessionStartAction::Fork);
|
||||||
|
{
|
||||||
|
let mut guard = PromptScreenGuard::enter(&mut tui)?;
|
||||||
|
guard.draw(&screen)?;
|
||||||
|
assert!(!guard.tui.is_alt_screen_active());
|
||||||
|
assert_eq!(
|
||||||
|
guard.tui.terminal.viewport_area,
|
||||||
|
Rect {
|
||||||
|
height: screen.content().desired_height(/*width*/ 80),
|
||||||
|
..original
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(tui.terminal.viewport_area, original);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn alternate_prompt_restores_original_viewport() -> Result<()> {
|
||||||
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||||
|
let original = Rect::new(
|
||||||
|
/*x*/ 0, /*y*/ 3, /*width*/ 80, /*height*/ 0,
|
||||||
|
);
|
||||||
|
tui.terminal.set_viewport_area(original);
|
||||||
|
{
|
||||||
|
let mut guard = PromptScreenGuard::enter(&mut tui)?;
|
||||||
|
guard.draw(&prompt(SessionStartAction::Fork))?;
|
||||||
|
assert!(guard.tui.is_alt_screen_active());
|
||||||
|
assert_eq!(
|
||||||
|
guard.tui.terminal.viewport_area,
|
||||||
|
Rect::new(
|
||||||
|
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!tui.is_alt_screen_active());
|
||||||
|
assert_eq!(tui.terminal.viewport_area, original);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in new issue
Block a user