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:
Jeremy Rose
2026-08-20 07:25:40 +00:00
committed by copyberry
parent fdc23b93b8
commit d944ce83a2
11 changed files with 736 additions and 57 deletions
+14
View File
@@ -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());
-25
View File
@@ -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;
+38 -5
View File
@@ -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(),
-27
View File
@@ -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 {
+2
View File
@@ -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))]
+127
View File
@@ -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;
+150
View File
@@ -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
@@ -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
+198
View File
@@ -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;
+174
View File
@@ -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(())
}