diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 610891ea3d..f8a9734729 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1413,6 +1413,11 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + if let Err(err) = startup_draft.update_session_selection(&mut tui, &session_selection) { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } + if matches!( &session_selection, resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_fresh_session_transitions.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_fresh_session_transitions.snap new file mode 100644 index 0000000000..32f6de9153 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_fresh_session_transitions.snap @@ -0,0 +1,87 @@ +--- +source: tui/src/startup_draft_tests.rs +expression: "snapshots.join(\"\\n---\\n\")" +--- +resume lookup fallback: +before: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + Resuming session… + +› draft while loading + + +after: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› draft while loading + + +--- +fork lookup fallback: +before: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + Forking session… + +› draft while loading + + +after: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› draft while loading + + +--- +resume picker cancellation: +before: +hidden while picker owns input +after: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› Ask Codex to do anything + + ? for shortcuts +--- +fork picker cancellation: +before: +hidden while picker owns input +after: +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› Ask Codex to do anything + + ? for shortcuts diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_full_frames.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_full_frames.snap index 7ee3bae3a3..9195af7637 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_full_frames.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_full_frames.snap @@ -14,6 +14,32 @@ empty (48 columns): › Ask Codex to do anything ? for shortcuts +--- +resuming (48 columns): +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + Resuming session… + +› Ask Codex to do anything + + ? for shortcuts +--- +forking (48 columns): +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + Forking session… + +› draft while loading + + --- multiline (48 columns): ╭───────────────────────────────────────╮ diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index c094adcca5..983443f342 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -15,6 +15,7 @@ use crossterm::event::KeyModifiers; use ratatui::layout::Size; use ratatui::style::Modifier; use ratatui::style::Style; +use ratatui::style::Stylize; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::unbounded_channel; use tokio_stream::Stream; @@ -38,6 +39,7 @@ use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt; use crate::render::renderable::RenderableItem; +use crate::resume_picker::SessionSelection; use crate::tui; use crate::tui::FrameRequester; use crate::tui::Tui; @@ -55,6 +57,14 @@ pub(crate) enum StartupDraftInitialScreen { SessionPicker, } +/// Describes the session being prepared behind the provisional composer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum StartupDraftSessionAction { + New, + Resume, + Fork, +} + /// Marks intentional startup cancellation so unrelated I/O interrupts remain errors. #[derive(Debug, thiserror::Error)] #[error("startup cancelled")] @@ -82,12 +92,16 @@ pub(crate) struct StartupDraftPump { events: Pin + Send>>, app_event_rx: UnboundedReceiver, initial_screen: StartupDraftInitialScreen, + session_action: StartupDraftSessionAction, pending_paste_newline: Option<(Instant, String)>, } impl StartupDraft { /// Initialize the terminal without showing a composer before an expected session picker. - pub(crate) fn new(initial_screen: StartupDraftInitialScreen) -> io::Result { + pub(crate) fn new( + initial_screen: StartupDraftInitialScreen, + session_action: StartupDraftSessionAction, + ) -> io::Result { let mut initialized_terminal = tui::init()?; let terminal_restore_guard = TerminalRestoreGuard::new(); initialized_terminal.terminal.clear()?; @@ -113,6 +127,7 @@ impl StartupDraft { events, app_event_rx, initial_screen, + session_action, pending_paste_newline: None, }, }; @@ -161,6 +176,27 @@ impl StartupDraftPump { } } + /// Align the provisional loading message with the resolved session selection. + pub(crate) fn update_session_selection( + &mut self, + tui: &mut Tui, + session_selection: &SessionSelection, + ) -> io::Result<()> { + let session_action = match session_selection { + SessionSelection::StartFresh | SessionSelection::Exit => StartupDraftSessionAction::New, + SessionSelection::Resume(_) => StartupDraftSessionAction::Resume, + SessionSelection::Fork(_) => StartupDraftSessionAction::Fork, + }; + if self.session_action == session_action { + return Ok(()); + } + self.session_action = session_action; + if self.initial_screen == StartupDraftInitialScreen::Composer { + self.draw(tui, tui.terminal.last_known_screen_size)?; + } + Ok(()) + } + /// Poll one existing startup future alongside the original terminal input stream. pub(crate) async fn run_until(&mut self, tui: &mut Tui, future: F) -> io::Result where @@ -336,7 +372,8 @@ impl StartupDraftPump { .schedule_frame_in(ChatComposer::recommended_paste_flush_delay()); } self.bottom_pane.pre_draw_tick(); - let renderable = startup_draft_renderable(&self.header, &self.bottom_pane); + let renderable = + startup_draft_renderable(&self.header, &self.bottom_pane, self.session_action); let desired_height = renderable.desired_height(screen_size.width); tui.draw_with_resize_reflow(desired_height, screen_size, |frame| { let area = frame.area(); @@ -427,15 +464,30 @@ fn startup_session_header(config: Option<&Config>) -> Box { fn startup_draft_renderable<'a>( header: &'a dyn Renderable, bottom_pane: &'a BottomPane, + session_action: StartupDraftSessionAction, ) -> RenderableItem<'a> { let mut renderable = FlexRenderable::new(); renderable.push(/*flex*/ 1, RenderableItem::Borrowed(header)); + let loading_message = match session_action { + StartupDraftSessionAction::New => None, + StartupDraftSessionAction::Resume => Some(" Resuming session…"), + StartupDraftSessionAction::Fork => Some(" Forking session…"), + }; + if let Some(loading_message) = loading_message { + renderable.push( + /*flex*/ 0, + RenderableItem::Owned(Box::new(loading_message.dim())), + ); + } renderable.push( /*flex*/ 0, bottom_pane .as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0) .inset(Insets::tlbr( - /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, + /*top*/ u16::from(loading_message.is_none()), + /*left*/ 0, + /*bottom*/ 0, + /*right*/ 0, )), ); RenderableItem::Owned(Box::new(renderable)) diff --git a/codex-rs/tui/src/startup_draft_tests.rs b/codex-rs/tui/src/startup_draft_tests.rs index 859daac12a..d037731f7f 100644 --- a/codex-rs/tui/src/startup_draft_tests.rs +++ b/codex-rs/tui/src/startup_draft_tests.rs @@ -9,6 +9,7 @@ use tokio::sync::mpsc::unbounded_channel; use super::StartupDraftInitialScreen; use super::StartupDraftPump; +use super::StartupDraftSessionAction; use super::handle_startup_draft_key; use super::startup_draft_bottom_pane; use super::startup_draft_renderable; @@ -16,6 +17,7 @@ use super::startup_session_header; use crate::app_event_sender::AppEventSender; use crate::legacy_core::config::ConfigBuilder; use crate::render::renderable::Renderable; +use crate::resume_picker::SessionSelection; use crate::tui::FrameRequester; use crate::tui::TuiEvent; @@ -34,6 +36,7 @@ where events: Box::pin(futures::stream::iter(events)), app_event_rx: rx, initial_screen: StartupDraftInitialScreen::Composer, + session_action: StartupDraftSessionAction::New, pending_paste_newline: None, } } @@ -43,14 +46,43 @@ fn startup_draft_renders_full_empty_and_multiline_composer_frames() { let mut pump = startup_test_pump(std::iter::empty()); let mut snapshots = Vec::new(); - for (label, width, text) in [ - ("empty", 48, ""), - ("multiline", 48, "first startup line\nsecond startup line"), - ("narrow", 18, "first startup line\nsecond startup line"), + for (label, width, text, session_action) in [ + ("empty", 48, "", StartupDraftSessionAction::New), + ("resuming", 48, "", StartupDraftSessionAction::Resume), + ( + "forking", + 48, + "draft while loading", + StartupDraftSessionAction::Fork, + ), + ( + "multiline", + 48, + "first startup line\nsecond startup line", + StartupDraftSessionAction::New, + ), + ( + "narrow", + 18, + "first startup line\nsecond startup line", + StartupDraftSessionAction::New, + ), ] { + pump.session_action = session_action; pump.bottom_pane .set_composer_text(text.to_string(), Vec::new(), Vec::new()); - let renderable = startup_draft_renderable(&pump.header, &pump.bottom_pane); + let renderable = + startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action); + assert_eq!( + renderable.desired_height(width), + startup_draft_renderable( + &pump.header, + &pump.bottom_pane, + StartupDraftSessionAction::New, + ) + .desired_height(width), + "loading status should reuse the existing gap above the composer" + ); let area = Rect::new( /*x*/ 0, /*y*/ 0, @@ -84,6 +116,93 @@ fn startup_draft_renders_full_empty_and_multiline_composer_frames() { insta::assert_snapshot!("startup_draft_full_frames", snapshots.join("\n---\n")); } +#[tokio::test] +async fn startup_draft_clears_loading_status_when_starting_fresh() { + let mut snapshots = Vec::new(); + let render_frame = |pump: &StartupDraftPump| { + let width = 48; + let renderable = + startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + renderable.desired_height(width), + ); + let mut buffer = Buffer::empty(area); + renderable.render(area, &mut buffer); + (0..area.height) + .map(|row| { + (0..area.width) + .map(|column| buffer[(column, row)].symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") + .replace(crate::version::CODEX_CLI_VERSION, "") + }; + + for (label, initial_screen, session_action) in [ + ( + "resume lookup fallback", + StartupDraftInitialScreen::Composer, + StartupDraftSessionAction::Resume, + ), + ( + "fork lookup fallback", + StartupDraftInitialScreen::Composer, + StartupDraftSessionAction::Fork, + ), + ( + "resume picker cancellation", + StartupDraftInitialScreen::SessionPicker, + StartupDraftSessionAction::Resume, + ), + ( + "fork picker cancellation", + StartupDraftInitialScreen::SessionPicker, + StartupDraftSessionAction::Fork, + ), + ] { + let mut pump = startup_test_pump(std::iter::empty()); + pump.initial_screen = initial_screen; + pump.session_action = session_action; + if initial_screen == StartupDraftInitialScreen::Composer { + pump.bottom_pane.insert_str("draft while loading"); + } + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + pump.show_initial_screen(&mut tui) + .expect("respect the initial composer or picker screen"); + let before = if tui.terminal.viewport_area.is_empty() { + "hidden while picker owns input".to_string() + } else { + render_frame(&pump) + }; + + pump.update_session_selection(&mut tui, &SessionSelection::StartFresh) + .expect("clear the loading status after a fresh-session selection"); + if initial_screen == StartupDraftInitialScreen::SessionPicker { + assert!(tui.terminal.viewport_area.is_empty()); + pump.show(&mut tui) + .expect("reveal the fresh-session composer after the picker"); + } + let after = render_frame(&pump); + assert!(!after.contains("Resuming session")); + assert!(!after.contains("Forking session")); + if initial_screen == StartupDraftInitialScreen::Composer { + assert_eq!(pump.bottom_pane.composer_text(), "draft while loading"); + } + snapshots.push(format!("{label}:\nbefore:\n{before}\nafter:\n{after}")); + } + + insta::assert_snapshot!( + "startup_draft_fresh_session_transitions", + snapshots.join("\n---\n") + ); +} + #[tokio::test] async fn startup_draft_hydrates_its_header_without_moving_the_composer() { let codex_home = tempfile::tempdir().expect("create temporary Codex home"); @@ -95,7 +214,8 @@ async fn startup_draft_hydrates_its_header_without_moving_the_composer() { let mut pump = startup_test_pump(std::iter::empty()); let width = 80; let initial_height = - startup_draft_renderable(&pump.header, &pump.bottom_pane).desired_height(width); + startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action) + .desired_height(width); assert_eq!( pump.header.raw_lines().last().map(ToString::to_string), @@ -114,7 +234,8 @@ async fn startup_draft_hydrates_its_header_without_moving_the_composer() { Some(expected_directory) ); assert_eq!( - startup_draft_renderable(&pump.header, &pump.bottom_pane).desired_height(width), + startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action) + .desired_height(width), initial_height ); } @@ -558,7 +679,7 @@ async fn startup_draft_waits_for_onboarding_before_accepting_input() { .expect("show the composer after onboarding finishes"); assert!(!tui.terminal.viewport_area.is_empty()); let area = tui.terminal.viewport_area; - let renderable = startup_draft_renderable(&pump.header, &pump.bottom_pane); + let renderable = startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action); let mut buffer = Buffer::empty(area); renderable.render(area, &mut buffer); let visible_frame = (area.top()..area.bottom()) diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index f0d9181188..04f61bb03a 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -165,7 +165,14 @@ pub(super) async fn run_main_inner( } else { startup_draft::StartupDraftInitialScreen::Composer }; - let mut startup_draft = startup_draft::StartupDraft::new(initial_screen)?; + let session_action = if cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some() { + startup_draft::StartupDraftSessionAction::Fork + } else if cli.resume_picker || cli.resume_last || cli.resume_session_id.is_some() { + startup_draft::StartupDraftSessionAction::Resume + } else { + startup_draft::StartupDraftSessionAction::New + }; + let mut startup_draft = startup_draft::StartupDraft::new(initial_screen, session_action)?; let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon { startup_draft diff --git a/codex-rs/tui/src/tui_startup_tests.rs b/codex-rs/tui/src/tui_startup_tests.rs index cb89994df8..19e1d337c8 100644 --- a/codex-rs/tui/src/tui_startup_tests.rs +++ b/codex-rs/tui/src/tui_startup_tests.rs @@ -202,6 +202,7 @@ async fn startup_typeahead_pty_child() { let signals = PathBuf::from(signals); let mut startup_draft = crate::startup_draft::StartupDraft::new( crate::startup_draft::StartupDraftInitialScreen::Composer, + crate::startup_draft::StartupDraftSessionAction::New, ) .expect("initialize the real startup composer");