mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +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:
@@ -2795,6 +2795,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli)
|
||||
strict_config,
|
||||
approval_policy,
|
||||
web_search,
|
||||
no_alt_screen,
|
||||
prompt,
|
||||
mut config_overrides,
|
||||
..
|
||||
@@ -2814,6 +2815,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli)
|
||||
if web_search {
|
||||
interactive.web_search = true;
|
||||
}
|
||||
interactive.no_alt_screen |= no_alt_screen;
|
||||
if strict_config {
|
||||
interactive.strict_config = true;
|
||||
}
|
||||
@@ -3772,6 +3774,18 @@ mod tests {
|
||||
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]
|
||||
fn resume_picker_logic_none_and_not_last() {
|
||||
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 })
|
||||
}
|
||||
|
||||
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> {
|
||||
let TypedRequestError::Server { method, source } = error else {
|
||||
return None;
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
//! remains isolated from protected interactive requests until the initialized composer owns it.
|
||||
|
||||
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> {
|
||||
let provider = create_model_provider(provider.clone(), /*auth_manager*/ None);
|
||||
@@ -304,10 +308,25 @@ impl App {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resumed) => resumed
|
||||
.map_err(|err| session_start_error("resume", &target_session, err))?,
|
||||
Ok(resumed) => resumed,
|
||||
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 {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
@@ -350,11 +369,25 @@ impl App {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(forked) => {
|
||||
forked.map_err(|err| session_start_error("fork", &target_session, err))?
|
||||
}
|
||||
Ok(forked) => forked,
|
||||
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 {
|
||||
config: config.clone(),
|
||||
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]
|
||||
fn active_turn_steer_race_detects_missing_active_turn() {
|
||||
let error = TypedRequestError::Server {
|
||||
|
||||
@@ -170,6 +170,7 @@ mod session_archive_commands;
|
||||
mod session_log;
|
||||
mod session_queue_commands;
|
||||
mod session_resume;
|
||||
mod session_start;
|
||||
mod session_state;
|
||||
mod skills_helpers;
|
||||
mod slash_command;
|
||||
@@ -195,6 +196,7 @@ mod tooltips;
|
||||
mod transcript_reflow;
|
||||
mod tui;
|
||||
mod ui_consts;
|
||||
mod unarchive_prompt;
|
||||
pub(crate) mod update_action;
|
||||
pub use update_action::UpdateAction;
|
||||
#[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