From d213a29c57c4ed491f4d9b473609ab7fd232f77c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 26 Sep 2026 18:49:53 +0000 Subject: [PATCH] Refresh the TUI welcome screen for new sessions (#48513) - Show a compact session header with a randomly selected greeting, preserving the greeting and blossom state from startup into the live session. - Center a settled blossom in unused fullscreen space and replay its animation on click. Hide it while typing or when space is insufficient, and respect `tui.animations` and `tui.effects.welcome`. - Hide startup tips in the fullscreen transcript while retaining them in terminal scrollback, and prevent hidden entries from shifting the first visible header. - Keep the composer visible and responsive during daemon startup, routing startup diagnostics through tracing while direct lifecycle commands retain stderr output. Add regression tests and update snapshots for blossom placement and click replay, welcome opt-outs, greeting stability, startup transitions, tip visibility, and transcript spacing. GitOrigin-RevId: ec7b082fbe25ced1180cad3348f5c76e48c0e92b (cherry picked from commit 7f6c0f9387a0a60f396f61cc58f6b38bc98f2473) --- codex-rs/app-server-daemon/src/launch.rs | 4 +- codex-rs/app-server-daemon/src/lib.rs | 25 ++- .../app-server-daemon/src/prepare_install.rs | 4 +- .../src/prepare_install_tests.rs | 1 + .../src/update_loop_tests.rs | 1 + codex-rs/tui/src/app/agents_overview_new.rs | 4 + .../src/app/empty_state_animation_tests.rs | 136 +++++++++++----- codex-rs/tui/src/app/empty_state_policy.rs | 26 +-- codex-rs/tui/src/app/history_ui.rs | 9 +- codex-rs/tui/src/app/owned_transcript.rs | 38 ++++- codex-rs/tui/src/app/session_lifecycle.rs | 11 +- ..._animation_tests__fresh_thread_header.snap | 61 ++++++- codex-rs/tui/src/app/startup.rs | 5 +- .../tests/background_task_defaults_tests.rs | 10 ++ ...rame_tests__startup_tip_in_transcript.snap | 31 ++-- .../tui/src/app/tests/startup_frame_tests.rs | 55 +++++-- .../bottom_pane/chat_composer/footer_state.rs | 1 + .../src/chatwidget/activity_presentation.rs | 4 +- codex-rs/tui/src/chatwidget/constructor.rs | 7 +- codex-rs/tui/src/chatwidget/session_flow.rs | 6 +- codex-rs/tui/src/empty_state_animation.rs | 117 ++++++++++++- .../src/empty_state_animation/greetings.rs | 108 ++++++++++++ .../tui/src/empty_state_animation/policy.rs | 18 -- .../tui/src/empty_state_animation_tests.rs | 154 +++++++++++++++++- codex-rs/tui/src/history_cell/session.rs | 95 +++++++++++ ...__layout__tests__owned_startup_layout.snap | 14 +- ...artup_draft_fresh_session_transitions.snap | 67 ++++---- ...aft__tests__startup_draft_full_frames.snap | 56 +++---- ...__startup_draft_onboarding_transition.snap | 12 +- ...raft__tests__terminal_app_ssh_startup.snap | 25 ++- codex-rs/tui/src/startup_draft.rs | 32 +++- codex-rs/tui/src/startup_draft_input.rs | 19 ++- codex-rs/tui/src/startup_draft_layout.rs | 76 ++++++--- .../tui/src/startup_draft_layout_tests.rs | 109 ++++++++++++- codex-rs/tui/src/startup_draft_tests.rs | 46 ++++-- codex-rs/tui/src/startup_orchestration.rs | 10 +- codex-rs/tui/src/startup_presentation.rs | 8 + codex-rs/tui/src/transcript_view.rs | 22 +++ codex-rs/tui/src/transcript_view/layout.rs | 12 +- codex-rs/tui/src/transcript_view/selection.rs | 14 +- codex-rs/tui/src/transcript_view_tests.rs | 54 ++++++ codex-rs/tui/src/tui_startup_tests.rs | 1 + 42 files changed, 1216 insertions(+), 292 deletions(-) create mode 100644 codex-rs/tui/src/empty_state_animation/greetings.rs diff --git a/codex-rs/app-server-daemon/src/launch.rs b/codex-rs/app-server-daemon/src/launch.rs index 04d58401a6..cd283a3c97 100644 --- a/codex-rs/app-server-daemon/src/launch.rs +++ b/codex-rs/app-server-daemon/src/launch.rs @@ -11,11 +11,13 @@ use std::collections::BTreeMap; /// Start a missing daemon with these features, preserving other saved overrides, /// or leave a running daemon unchanged. /// Callers must check the running server's configuration before using it. +/// Startup diagnostics go to tracing, so a caller with a live TUI can keep rendering. pub async fn start_with_features(features: &BTreeMap) -> Result { ensure_supported_platform()?; #[cfg(windows)] crate::backend::windows::ensure_not_elevated()?; - let daemon = Daemon::from_environment()?; + let mut daemon = Daemon::from_environment()?; + daemon.log_diagnostics = true; let _operation_lock = daemon.acquire_operation_lock().await?; let selected = daemon.current_installation()?; let mut overrides = selected.load_settings().await?.feature_overrides; diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 54ca92b6c1..15ba7692b9 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -306,6 +306,9 @@ fn ensure_supported_platform() -> Result<()> { #[derive(Clone)] struct Daemon { + // Feature-aware TUI startup owns a live terminal. Direct lifecycle commands + // must still report their diagnostics to stderr. + log_diagnostics: bool, socket_path: PathBuf, pid_file: PathBuf, update_pid_file: PathBuf, @@ -330,6 +333,7 @@ impl Daemon { (DAEMON_PID_FILE_NAME, DAEMON_UPDATE_PID_FILE_NAME) }; Ok(Self { + log_diagnostics: false, socket_path, pid_file: state_dir.join(pid_file), update_pid_file: state_dir.join(update_pid_file), @@ -339,6 +343,14 @@ impl Daemon { }) } + fn diagnostic(&self, message: std::fmt::Arguments<'_>) { + if self.log_diagnostics { + tracing::info!("{message}"); + } else { + eprintln!("{message}"); + } + } + fn recovery_file(&self) -> Result { Ok(codex_app_server_transport::daemon_recovery_file_path( self.settings_file @@ -411,7 +423,9 @@ impl Daemon { } else { // A fresh start must ignore snapshots left by older stop clients. if let Err(err) = thread_recovery::discard_pending(self) { - eprintln!("warning: failed to clear stale daemon recovery before start: {err}"); + self.diagnostic(format_args!( + "warning: failed to clear stale daemon recovery before start: {err}" + )); } prepare_install::prepare(self, &settings).await?; managed.managed_codex_bin = self.current_managed_codex_bin()?; @@ -433,7 +447,9 @@ impl Daemon { if backend.is_some() && let Err(err) = managed.ensure_managed_updater(&settings).await { - eprintln!("warning: failed to ensure managed updater after app-server start: {err:#}"); + self.diagnostic(format_args!( + "warning: failed to ensure managed updater after app-server start: {err:#}" + )); } Ok(managed .output(status, backend, pid, Some(info.app_server_version)) @@ -1252,6 +1268,7 @@ mod tests { let legacy = home.path().join("packages/standalone/current"); std::fs::create_dir_all(&legacy).expect("legacy selection"); let daemon = Daemon { + log_diagnostics: false, socket_path: home.path().join("server.sock"), pid_file: state.join(super::LEGACY_PID_FILE_NAME), update_pid_file: state.join(super::LEGACY_UPDATE_PID_FILE_NAME), @@ -1285,6 +1302,7 @@ mod tests { let temp = TempDir::new().expect("temp dir"); let state = temp.path().join("missing-home").join("daemon-state"); let daemon = Daemon { + log_diagnostics: false, socket_path: state.join("server.sock"), pid_file: state.join("server.pid"), update_pid_file: state.join("updater.pid"), @@ -1310,6 +1328,7 @@ mod tests { .await .expect("private state directory"); let daemon = Daemon { + log_diagnostics: false, socket_path: home.path().join("server.sock"), pid_file: state.join("server.pid"), update_pid_file: state.join("updater.pid"), @@ -1364,6 +1383,7 @@ mod tests { .expect("current local build"); let state = home.path().join("app-server-daemon"); let daemon = Daemon { + log_diagnostics: false, socket_path: home .path() .join("app-server-control/app-server-control.sock"), @@ -1391,6 +1411,7 @@ mod tests { async fn not_ready_context_reports_daemon_app_server_before_stderr() { let temp_dir = TempDir::new().expect("temp dir"); let daemon = Daemon { + log_diagnostics: false, socket_path: temp_dir.path().join("app-server-control.sock"), pid_file: temp_dir.path().join("app-server.pid"), update_pid_file: temp_dir.path().join("app-server-updater.pid"), diff --git a/codex-rs/app-server-daemon/src/prepare_install.rs b/codex-rs/app-server-daemon/src/prepare_install.rs index 290ceae25d..ecf2501d22 100644 --- a/codex-rs/app-server-daemon/src/prepare_install.rs +++ b/codex-rs/app-server-daemon/src/prepare_install.rs @@ -184,10 +184,10 @@ async fn prepare_from_package( return Ok(false); } } else { - eprintln!( + daemon.diagnostic(format_args!( "Installing daemon from CLI version {version} into {}...", root.display() - ); + )); } // Confirmation must not block lifecycle commands. Recheck the approved // selection and running state once this operation owns both locks. diff --git a/codex-rs/app-server-daemon/src/prepare_install_tests.rs b/codex-rs/app-server-daemon/src/prepare_install_tests.rs index 45cfee41f7..810a45b433 100644 --- a/codex-rs/app-server-daemon/src/prepare_install_tests.rs +++ b/codex-rs/app-server-daemon/src/prepare_install_tests.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; fn daemon(home: &std::path::Path) -> crate::Daemon { let state = home.join("app-server-daemon"); crate::Daemon { + log_diagnostics: false, socket_path: state.join("app-server.sock"), pid_file: state.join("app-server.pid"), update_pid_file: state.join("app-server-updater.pid"), diff --git a/codex-rs/app-server-daemon/src/update_loop_tests.rs b/codex-rs/app-server-daemon/src/update_loop_tests.rs index 6dcabe53fe..d932742371 100644 --- a/codex-rs/app-server-daemon/src/update_loop_tests.rs +++ b/codex-rs/app-server-daemon/src/update_loop_tests.rs @@ -301,6 +301,7 @@ fn manual_update_daemon(home: &TempDir) -> (Daemon, String) { std::fs::write(state.join("app-server.stderr.log"), b"").unwrap(); ( Daemon { + log_diagnostics: false, socket_path: home.path().join("app-server-control/server.sock"), pid_file: state.join("app-server.pid"), update_pid_file: state.join("app-server-updater.pid"), diff --git a/codex-rs/tui/src/app/agents_overview_new.rs b/codex-rs/tui/src/app/agents_overview_new.rs index 7a4aa8aa20..d422598d5a 100644 --- a/codex-rs/tui/src/app/agents_overview_new.rs +++ b/codex-rs/tui/src/app/agents_overview_new.rs @@ -74,6 +74,10 @@ impl App { .await; if self.current_displayed_thread_id() != previous_thread { draft.flush_pending_paste_newline(tui).await?; + self.chat_widget + .empty_state_animation + .borrow_mut() + .continue_from(&mut draft.blossom.borrow_mut()); self.chat_widget.restore_startup_draft(draft.take_draft()); } else { // Retain edits if setup fails so retrying `n` does not lose the draft. diff --git a/codex-rs/tui/src/app/empty_state_animation_tests.rs b/codex-rs/tui/src/app/empty_state_animation_tests.rs index a7b53d490f..f8195f1266 100644 --- a/codex-rs/tui/src/app/empty_state_animation_tests.rs +++ b/codex-rs/tui/src/app/empty_state_animation_tests.rs @@ -5,18 +5,12 @@ use super::*; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; -fn animation_is_eligible(app: &App) -> bool { - app.empty_state_presentation( - crate::motion::MotionMode::from_animations_enabled( - app.local_settings.tui.animations && app.local_settings.tui.effects.welcome, - ), - /*focused*/ true, - ) == crate::empty_state_animation::Presentation::Animated - && app - .chat_widget - .empty_state_animation - .borrow() - .is_eligible_for_test() +fn has_blossom(tui: &tui::Tui) -> bool { + text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal, + )) + .chars() + .any(|ch| ('\u{2801}'..='\u{28ff}').contains(&ch)) } fn text(buffer: &Buffer) -> String { @@ -56,37 +50,37 @@ async fn fresh_logo_returns_only_to_the_ordinary_composer() -> Result<()> { .start_fresh(); draw(&mut app, &mut tui, size)?; let cursor = tui.terminal.last_known_cursor_pos; - assert!(animation_is_eligible(&app)); + assert!(has_blossom(&tui)); app.open_transcript_overlay(&mut tui); draw(&mut app, &mut tui, size)?; - assert!(!animation_is_eligible(&app)); + assert!(!has_blossom(&tui)); assert_eq!(tui.terminal.last_known_cursor_pos, cursor); app.close_transcript_overlay(&mut tui); draw(&mut app, &mut tui, size)?; - assert!(animation_is_eligible(&app)); + assert!(has_blossom(&tui)); app.transcript_view.begin_search(); draw(&mut app, &mut tui, size)?; - assert!(!animation_is_eligible(&app)); + assert!(!has_blossom(&tui)); app.transcript_view.handle_key( KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &app.transcript_cells, ); draw(&mut app, &mut tui, size)?; - assert!(animation_is_eligible(&app)); + assert!(has_blossom(&tui)); for enabled in [true, false] { app.apply_raw_output_mode(&mut tui, enabled, /*notify*/ false); draw(&mut app, &mut tui, size)?; - assert_eq!(animation_is_eligible(&app), !enabled); + assert_eq!(has_blossom(&tui), !enabled); } app.chat_widget.apply_external_edit(String::new()); for (key, visible) in [(KeyCode::Char('?'), false), (KeyCode::Right, true)] { app.chat_widget .handle_key_event(KeyEvent::new(key, KeyModifiers::NONE)); draw(&mut app, &mut tui, size)?; - assert_eq!(animation_is_eligible(&app), visible); + assert_eq!(has_blossom(&tui), visible); } tui.set_owned_screen(/*owned*/ false)?; Ok(()) @@ -96,7 +90,8 @@ async fn fresh_logo_returns_only_to_the_ordinary_composer() -> Result<()> { async fn non_startup_history_dismisses_logo_until_a_new_thread() -> Result<()> { let mut app = crate::app::test_support::make_test_app().await; app.local_settings.tui.animations = true; - let size = Size::new(/*width*/ 120, /*height*/ 44); + // Leave room for the full release-note card above the centered logo. + let size = Size::new(/*width*/ 120, /*height*/ 64); let mut tui = crate::tui::test_support::make_test_tui()?; tui.set_owned_screen(/*owned*/ true)?; app.chat_widget @@ -142,7 +137,7 @@ async fn non_startup_history_dismisses_logo_until_a_new_thread() -> Result<()> { )), ); draw(&mut app, &mut tui, size)?; - assert!(animation_is_eligible(&app)); + assert!(has_blossom(&tui)); crate::chatwidget::tests::set_chatgpt_auth(&mut app.chat_widget); let request = app.chat_widget.start_rate_limit_reset_startup_check(); @@ -157,10 +152,10 @@ async fn non_startup_history_dismisses_logo_until_a_new_thread() -> Result<()> { // The same startup notice stays eligible both pending and committed to history. assert!(app.chat_widget.empty_state_composer().is_some()); draw(&mut app, &mut tui, size)?; - assert!(animation_is_eligible(&app)); + assert!(has_blossom(&tui)); app.insert_pending_usage_output_if_ready(&mut tui); draw(&mut app, &mut tui, size)?; - assert!(animation_is_eligible(&app)); + assert!(has_blossom(&tui)); // Even invisible non-startup content ends eligibility before a frame or clear can erase it. app.insert_history_cell( @@ -170,13 +165,13 @@ async fn non_startup_history_dismisses_logo_until_a_new_thread() -> Result<()> { app.reset_transcript_state_after_clear(); app.queue_clear_ui_header(&mut tui); draw(&mut app, &mut tui, size)?; - assert!(!animation_is_eligible(&app)); + assert!(!has_blossom(&tui)); app.chat_widget .empty_state_animation .borrow_mut() .start_fresh(); draw(&mut app, &mut tui, size)?; - assert!(animation_is_eligible(&app)); + assert!(has_blossom(&tui)); tui.set_owned_screen(/*owned*/ false)?; Ok(()) } @@ -188,6 +183,14 @@ async fn empty_state_animation_preserves_header_cursor_and_footer() -> Result<() let size = Size::new(/*width*/ 120, /*height*/ 44); let mut tui = crate::tui::test_support::make_test_tui()?; tui.set_owned_screen(/*owned*/ true)?; + app.chat_widget + .empty_state_animation + .borrow() + .greeting + .set(crate::empty_state_animation::Greeting { + phrase: "Pull up a prompt.", + }) + .expect("initial greeting"); app.queue_clear_ui_header(&mut tui); app.transcript_cells .push(Arc::new(history_cell::StartupWarningsCell::mcp( @@ -199,32 +202,54 @@ async fn empty_state_animation_preserves_header_cursor_and_footer() -> Result<() let history_len = app.transcript_cells.len(); let before = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone(); let cursor = tui.terminal.last_known_cursor_pos; - assert!(!animation_is_eligible(&app)); + assert!(!has_blossom(&tui)); app.chat_widget .empty_state_animation .borrow_mut() .start_fresh(); assert_eq!(draw(&mut app, &mut tui, size)?, before_bottom); let after = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); - assert!(animation_is_eligible(&app)); + assert!(has_blossom(&tui)); assert_eq!(tui.terminal.last_known_cursor_pos, cursor); - for (a, b) in before.content.iter().zip(&after.content) { - if a.symbol() != " " { - assert_eq!(a, b); - } - } + assert!(text(&before).contains("Pull up a prompt.")); assert_eq!( &after.content[after.index_of(/*x*/ 0, before_bottom.y)..], &before.content[before.index_of(/*x*/ 0, before_bottom.y)..] ); - insta::assert_snapshot!("fresh_thread_header", text(after)); + insta::assert_snapshot!( + "fresh_thread_header", + format!( + "enabled:\n{}\n---\ndisabled:\n{}", + text(after), + text(&before) + ) + ); + // Editing, clearing, and resizing must never choose another phrase. + let phrase_pos = after + .content + .iter() + .position(|cell| cell.symbol() == "P") + .expect("the phrase is visible above the composer"); + assert_eq!(after.content[phrase_pos].fg, crate::style::accent_color()); + + let short = Size::new(/*width*/ 120, /*height*/ 12); + draw(&mut app, &mut tui, short)?; + assert!(!has_blossom(&tui)); + draw(&mut app, &mut tui, size)?; + assert!(has_blossom(&tui)); app.chat_widget.apply_external_edit("/m".to_string()); draw(&mut app, &mut tui, size)?; - assert!(!animation_is_eligible(&app)); + assert!(!has_blossom(&tui)); app.chat_widget.apply_external_edit(String::new()); draw(&mut app, &mut tui, size)?; - assert!(animation_is_eligible(&app)); + assert!(has_blossom(&tui)); + assert!( + text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal + )) + .contains("Pull up a prompt.") + ); assert_eq!(app.transcript_cells.len(), history_len); app.local_settings.tui.animations = false; draw(&mut app, &mut tui, size)?; @@ -240,13 +265,13 @@ async fn empty_state_animation_preserves_header_cursor_and_footer() -> Result<() app.local_settings.tui.effects.welcome = true; app.local_settings.tui.effects.shimmer = false; draw(&mut app, &mut tui, size)?; - assert!(animation_is_eligible(&app)); + assert!(has_blossom(&tui)); tui.set_owned_screen(/*owned*/ false)?; Ok(()) } #[tokio::test] -async fn submitting_a_draft_dismisses_logo_even_after_clear() -> Result<()> { +async fn submitting_a_draft_keeps_greeting_and_dismisses_logo_even_after_clear() -> Result<()> { let (mut app, _events, _ops) = crate::app::tests::make_test_app_with_channels().await; app.local_settings.tui.animations = true; let size = Size::new(/*width*/ 120, /*height*/ 44); @@ -258,20 +283,42 @@ async fn submitting_a_draft_dismisses_logo_even_after_clear() -> Result<()> { .borrow_mut() .start_fresh(); draw(&mut app, &mut tui, size)?; - assert!(animation_is_eligible(&app)); + assert!(has_blossom(&tui)); + let greeting = *app + .chat_widget + .empty_state_animation + .borrow() + .greeting + .get() + .expect("choose a greeting for the fresh thread"); app.chat_widget .apply_external_edit("first prompt".to_string()); draw(&mut app, &mut tui, size)?; let drafting = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); - assert!(!animation_is_eligible(&app)); + assert!(!has_blossom(&tui)); assert!(text(drafting).contains("first prompt")); + assert!(text(drafting).contains(greeting.phrase)); + app.chat_widget.apply_external_edit(String::new()); + draw(&mut app, &mut tui, size)?; + assert!(has_blossom(&tui)); + app.chat_widget + .apply_external_edit("first prompt".to_string()); app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); draw(&mut app, &mut tui, size)?; - assert!(!animation_is_eligible(&app)); + assert!(!has_blossom(&tui)); + let submitted = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + assert_eq!(text(submitted).matches(greeting.phrase).count(), 1); + // The header is also stable when it scrolls out and back into the viewport. + let short = Size::new(/*width*/ 120, /*height*/ 8); + draw(&mut app, &mut tui, short)?; + draw(&mut app, &mut tui, size)?; + let restored = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + assert!(text(restored).contains(greeting.phrase)); + assert!(!has_blossom(&tui)); app.reset_transcript_state_after_clear(); draw(&mut app, &mut tui, size)?; - assert!(!animation_is_eligible(&app)); + assert!(!has_blossom(&tui)); tui.set_owned_screen(/*owned*/ false)?; Ok(()) } @@ -311,7 +358,7 @@ async fn empty_state_animation_survives_plain_transcript_clicks() -> Result<()> &app.transcript_cells, ); draw(&mut app, &mut tui, size)?; - assert_eq!(animation_is_eligible(&app), visible); + assert_eq!(has_blossom(&tui), visible); if visible { assert_eq!(tui.terminal.last_known_cursor_pos, cursor); } @@ -323,7 +370,10 @@ async fn empty_state_animation_survives_plain_transcript_clicks() -> Result<()> ); app.transcript_view.end_selection(&app.transcript_cells); draw(&mut app, &mut tui, size)?; - assert!(animation_is_eligible(&app)); + assert!(!has_blossom(&tui)); + app.transcript_view.jump_to_latest(); + draw(&mut app, &mut tui, size)?; + assert!(has_blossom(&tui)); tui.set_owned_screen(/*owned*/ false)?; Ok(()) } diff --git a/codex-rs/tui/src/app/empty_state_policy.rs b/codex-rs/tui/src/app/empty_state_policy.rs index c134739f70..c579207418 100644 --- a/codex-rs/tui/src/app/empty_state_policy.rs +++ b/codex-rs/tui/src/app/empty_state_policy.rs @@ -2,17 +2,12 @@ //! View changes hide it temporarily; non-startup content dismisses it until a new thread. use super::*; -use crate::empty_state_animation::Presentation; +use crate::empty_state_animation::ComposerState; use crate::empty_state_animation::is_startup_cell; use crate::history_cell::HistoryRenderMode; -use crate::motion::MotionMode; impl App { - pub(super) fn empty_state_presentation( - &self, - motion: MotionMode, - focused: bool, - ) -> Presentation { + pub(super) fn first_screen_composer(&self) -> Option { if !self .transcript_cells .iter() @@ -26,7 +21,7 @@ impl App { // Evaluate live content even while a different view temporarily owns the screen. let composer = self.chat_widget.empty_state_composer(); let view = &self.transcript_view; - let composer = match self.chat_widget.history_render_mode() { + match self.chat_widget.history_render_mode() { HistoryRenderMode::Raw => None, HistoryRenderMode::Rich => match ( self.overlay.as_ref(), @@ -36,10 +31,19 @@ impl App { view.has_selection_range(), ) { // A refocus click without selected text remains an ordinary conversation. - (None, false, false, false, false) => composer, + (None, false, false, false, false) + if (view.is_following() || view.has_pending_latest_selection()) + && self.local_settings.tui.effects.welcome + && self + .chat_widget + .empty_state_animation + .borrow() + .is_eligible() => + { + composer + } _ => None, }, - }; - Presentation::for_composer(composer, motion, focused) + } } } diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index 63bd58185e..270829d2f4 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -315,7 +315,7 @@ impl App { &self, version: &'static str, ) -> history_cell::SessionHeaderHistoryCell { - history_cell::SessionHeaderHistoryCell::new( + let mut header = history_cell::SessionHeaderHistoryCell::new( self.chat_widget.model_display_name().to_string(), self.chat_widget.current_reasoning_effort(), self.chat_widget.should_show_fast_status( @@ -325,7 +325,12 @@ impl App { self.config.cwd.to_path_buf(), version, ) - .with_yolo_mode(history_cell::is_yolo_mode(&self.config)) + .with_yolo_mode(history_cell::is_yolo_mode(&self.config)); + history_cell::set_session_greeting( + &mut header, + &self.chat_widget.empty_state_animation.borrow().greeting, + ); + header } pub(super) fn clear_ui_header_lines(&self, width: u16) -> Vec> { diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index c4b9dbd2c3..91faa302b2 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -72,13 +72,7 @@ impl App { let motion = MotionMode::from_animations_enabled( self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer, ); - let focused = tui.is_terminal_focused(); - self.empty_state_presentation( - MotionMode::from_animations_enabled( - self.local_settings.tui.animations && self.local_settings.tui.effects.welcome, - ), - focused, - ); + let composer = self.first_screen_composer(); let latest_navigation = if self.enter_returns_to_latest() { "enter/esc latest" } else { @@ -148,6 +142,7 @@ impl App { let mut rendered_cursor = None; let mut footer_height_changed = false; let mut feedback_tick = None; + let mut blossom_tick = None; let mut transcript_bottom = available.saturating_sub(u16::from(composer_gap.is_none())); tui.draw(screen_size.height, |frame| { ratatui::widgets::Clear.render( @@ -223,6 +218,18 @@ impl App { ); footer_height_changed = false; } + blossom_tick = chat_widget + .empty_state_animation + .borrow_mut() + .render_first_screen( + Rect { + width: screen_size.width, + ..view.remaining_area() + }, + frame.buffer, + composer, + MotionMode::from_animations_enabled(self.local_settings.tui.animations), + ); bottom.render(bottom_area, frame.buffer); if let (Some(tip), Some(area)) = (completion_tip, completion_tip_area) { tip.render(area, frame.buffer); @@ -263,6 +270,9 @@ impl App { if let Some(delay) = feedback_tick { tui.frame_requester().schedule_frame_in(delay); } + if let Some(delay) = blossom_tick { + tui.frame_requester().schedule_frame_in(delay); + } let animating = view.is_following() && active_key.is_some_and(|key| key.animation_tick.is_some()); let loading = view.is_loading_history() && motion == MotionMode::Animated; @@ -297,6 +307,10 @@ impl App { return Ok(false); } if matches!(event, TuiEvent::FocusLost) { + self.chat_widget + .empty_state_animation + .borrow_mut() + .cancel_replay(); // Show the static, faded decoration immediately when the terminal loses focus. tui.frame_requester().schedule_frame(); } @@ -320,6 +334,16 @@ impl App { let size = tui.prepare_draw_size()?; self.render_owned_transcript(tui, size)?; } + if self.chat_widget.no_modal_or_popup_active() + && self + .chat_widget + .empty_state_animation + .borrow_mut() + .handle_mouse(*mouse) + { + tui.frame_requester().schedule_frame(); + return Ok(true); + } if composer_ready && self.handle_composer_copy_event(tui, event, |tui, text| { tui.copy_transcript_selection( diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index dabf197d30..879c20cef0 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -1097,10 +1097,13 @@ impl App { ThreadAttachPresentation::Fresh | ThreadAttachPresentation::FreshWithDraft ) { self.chat_widget.mark_fresh_task_for_sparkle(&started); - self.chat_widget - .empty_state_animation - .borrow_mut() - .start_fresh(); + // FreshWithDraft inherits its provisional greeting and replay at the handoff. + if matches!(presentation, ThreadAttachPresentation::Fresh) { + self.chat_widget + .empty_state_animation + .borrow_mut() + .start_fresh(); + } } self.chat_widget .set_task_mentions_enabled(started.task_tools_available); diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__empty_state_animation_tests__fresh_thread_header.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__empty_state_animation_tests__fresh_thread_header.snap index 03bca75b48..1261499117 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__empty_state_animation_tests__fresh_thread_header.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__empty_state_animation_tests__fresh_thread_header.snap @@ -1,13 +1,60 @@ --- source: tui/src/app/empty_state_animation_tests.rs -expression: text(after) +expression: "format!(\"enabled:\\n{}\\n---\\ndisabled:\\n{}\", text(after), text(&before))" --- -╭───────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ model: GPT-5.6-Sol /model to change │ -│ directory: /tmp/project │ -╰───────────────────────────────────────────╯ +enabled: + + >_ OpenAI Codex (v0.0.0) + /tmp/project + + Pull up a prompt. + + + + + + + + + ⣀⣤⣤⣤⣀⣀ + ⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀ + ⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄ + ⣀⣾⣿⣿⠃ ⣀⣴⣾⣿⣿⡿⠟⠋ ⣀⡀ ⠈⠙⢿⣿⣿⣧ + ⣀⣶⣿⣿⣿⣿⡇ ⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇ + ⣰⣿⣿⡿⢿⣿⣿⡇ ⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷ + ⢰⣿⣿⡟⠁⢸⣿⣿⡇ ⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀ + ⣾⣿⣿⠃ ⢸⣿⣿⡇ ⢸⣿⣿⠋⠁ ⠈⠙⢻⣿⣿⣿⣶⣤⡀ ⠹⢿⣿⣷⡄ + ⢸⣿⣿⣇ ⠸⣿⣿⣷⣦⣼⣿⣿ ⢸⣿⣿⠻⢿⣿⣿⡇ ⠘⣿⣿⣿ + ⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀ ⢀⣠⣼⣿⣿ ⣿⣿⡇ ⣿⣿⣿⠇ + ⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿ ⣿⣿⡇ ⣠⣿⣿⣿ + ⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿ ⣿⣿⣷⣴⣿⣿⣿⠃ + ⠘⣿⣿⣷ ⠈⠛⢿⣿⣿⣿⠿⠋⠉ ⣠⣴⣿⣿⣿ ⣿⣿⣿⣿⣿⠟⠁ + ⠻⣿⣿⣿⣤⣀ ⠈⠉ ⢀⣤⣶⣿⣿⣿⠿⠟⠁ ⣰⣿⣿⡟⠋⠁ + ⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁ ⣠⣼⣿⣿⡟ + ⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏ + ⠈⠙⠛⠿⠿⠿⠿⠛⠉ + + + + + + + + + + +› Ask Codex to do anything + + GPT-5.6-Sol default · /tmp/project + ? for shortcuts ⚠ 1 warning · f2 to view +--- +disabled: + + >_ OpenAI Codex (v0.0.0) + /tmp/project + + Pull up a prompt. + diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index ca3c2f4800..6b283327a7 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -698,7 +698,10 @@ impl App { } chat_widget.note_rendered_width(tui.terminal.last_known_screen_size.width); if pending_startup_thread_start && !start_in_agents_overview { - chat_widget.empty_state_animation.borrow_mut().start_fresh(); + chat_widget + .empty_state_animation + .borrow_mut() + .continue_from(&mut startup_draft.blossom.borrow_mut()); } chat_widget.remote_connection = remote_connection; chat_widget.snapshot_local_images = app_server_target.uses_remote_workspace(); diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index f0f7b943af..2a17d7aeec 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -75,6 +75,16 @@ async fn command_center_new_keeps_startup_draft_visible_through_handoff() -> Res app.chat_widget.composer_text_with_pending(), "draft during startup" ); + assert_eq!( + app.chat_widget + .empty_state_animation + .borrow() + .greeting + .get() + .unwrap() + .phrase, + "Pull up a prompt." + ); while let Ok(event) = events.try_recv() { Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_frame_tests__startup_tip_in_transcript.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_frame_tests__startup_tip_in_transcript.snap index 5a78a900ac..8ed7c18cff 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_frame_tests__startup_tip_in_transcript.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_frame_tests__startup_tip_in_transcript.snap @@ -1,16 +1,14 @@ --- source: tui/src/app/tests/startup_frame_tests.rs +assertion_line: 72 expression: "normalize_snapshot_paths(normalize_agent_center_snapshot(snapshots.join(\"\\n\\n\")))" --- 80 columns -╭────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ model: gpt-test /model to change │ -│ directory: /tmp/project │ -╰────────────────────────────────────────╯ - Tip: Use /mcp to list configured MCP tools. + >_ OpenAI Codex (v0.0.0) + /tmp/project + + Pull up a prompt. • Conversation continues here. @@ -19,21 +17,20 @@ expression: "normalize_snapshot_paths(normalize_agent_center_snapshot(snapshots. + + + › Ask Codex to do anything GPT-5.6-Sol default · /tmp/project ? for shortcuts 32 columns -╭──────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ model: gpt-test /mode… │ -│ directory: /tmp/project │ -╰──────────────────────────────╯ - Tip: Use /mcp to list - configured MCP tools. + >_ OpenAI Codex (v0.0.0) + /tmp/project + + Pull up a prompt. • Conversation continues here. @@ -41,6 +38,10 @@ expression: "normalize_snapshot_paths(normalize_agent_center_snapshot(snapshots. + + + + › Ask Codex to do anything GPT-5.6-Sol default · /tmp/pr… diff --git a/codex-rs/tui/src/app/tests/startup_frame_tests.rs b/codex-rs/tui/src/app/tests/startup_frame_tests.rs index e9f983be54..97a99c04df 100644 --- a/codex-rs/tui/src/app/tests/startup_frame_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_frame_tests.rs @@ -4,6 +4,7 @@ use super::*; use crate::chatwidget::tests::helpers::normalize_agent_center_snapshot; use crate::chatwidget::tests::helpers::normalize_snapshot_paths; use crate::custom_terminal::test_support::last_rendered_buffer; +use crate::history_cell::HistoryRenderMode; use crate::startup_draft::tests::quiet_startup_test_pump; use pretty_assertions::assert_eq; @@ -16,23 +17,31 @@ fn frame_text(tui: &tui::Tui) -> String { } #[tokio::test] -async fn owned_startup_tip_follows_splash_in_transcript() -> Result<()> { +async fn owned_startup_hides_tip_in_transcript() -> Result<()> { let mut app = crate::app::test_support::make_test_app().await; app.local_settings.tui.show_tooltips = true; app.local_settings.tui.animations = false; + let greeting = Arc::new(std::sync::OnceLock::new()); + greeting + .set(crate::empty_state_animation::Greeting { + phrase: "Pull up a prompt.", + }) + .unwrap(); let session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()); + let mut session_info = new_session_info( + &app.config, + &app.local_settings, + &session.model, + &session.model, + &session, + /*is_first_event*/ false, + Some("Use /mcp to list configured MCP tools.".into()), + /*auth_plan*/ None, + /*show_fast_status*/ false, + ); + history_cell::set_session_greeting(&mut session_info, &greeting); app.transcript_cells = vec![ - Arc::new(new_session_info( - &app.config, - &app.local_settings, - &session.model, - &session.model, - &session, - /*is_first_event*/ false, - Some("Use /mcp to list configured MCP tools.".into()), - /*auth_plan*/ None, - /*show_fast_status*/ false, - )), + Arc::new(session_info), Arc::new(AgentMessageCell::new( vec!["Conversation continues here.".into()], /*is_first_line*/ true, @@ -64,6 +73,28 @@ async fn owned_startup_tip_follows_splash_in_transcript() -> Result<()> { "startup_tip_in_transcript", normalize_snapshot_paths(normalize_agent_center_snapshot(snapshots.join("\n\n"))), ); + // The source remains available to terminal scrollback in both presentation modes. + for mode in [HistoryRenderMode::Rich, HistoryRenderMode::Raw] { + let lines = app.transcript_cells[0].display_lines_for_mode(/*width*/ 80, mode); + let text = lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert!(text.contains("Pull up a prompt.")); + assert!(text.contains("Use /mcp")); + assert!(!text.contains("model:")); + assert!(!text.contains('╭')); + } + app.transcript_view.begin_search(); + app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 20))?; + assert!(!frame_text(&tui).contains("Use /mcp")); + app.open_transcript_overlay(&mut tui); + app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 20))?; + assert!(!frame_text(&tui).contains("Use /mcp")); + app.apply_raw_output_mode(&mut tui, /*enabled*/ true, /*notify*/ false); + app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 20))?; + assert!(!frame_text(&tui).contains("Use /mcp")); tui.set_owned_screen(/*owned*/ false)?; Ok(()) } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index 9cdae65217..1a22fd5ed2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -81,6 +81,7 @@ impl super::ChatComposer { use crate::empty_state_animation::ComposerState; let composer = match self.footer_mode() { + FooterMode::ComposerEmpty if self.is_in_paste_burst() => Some(ComposerState::Draft), FooterMode::ComposerEmpty => Some(ComposerState::Empty), FooterMode::ComposerHasDraft => Some(ComposerState::Draft), FooterMode::HistorySearch diff --git a/codex-rs/tui/src/chatwidget/activity_presentation.rs b/codex-rs/tui/src/chatwidget/activity_presentation.rs index 55c11ccd6d..bbafee3f0b 100644 --- a/codex-rs/tui/src/chatwidget/activity_presentation.rs +++ b/codex-rs/tui/src/chatwidget/activity_presentation.rs @@ -21,7 +21,9 @@ impl ChatWidget { let active = self.transcript.active_cell.as_deref(); let mode = self.history_render_mode(); let render_compact = |cell: &dyn HistoryCell, width| { - if mode == HistoryRenderMode::Raw { + if cell.as_any().is::() { + history_cell::fullscreen_session_lines(cell, width, /*detailed*/ false, mode) + } else if mode == HistoryRenderMode::Raw { cell.display_hyperlink_lines_for_mode(width, mode) } else { cell.compact_hyperlink_lines(width) diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index aee46661dc..dcfb96e8b3 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -63,7 +63,10 @@ impl ChatWidget { settings: fallback_default, }; - let active_cell = Some(Self::placeholder_session_header_cell(&config)); + let empty_state_animation = crate::empty_state_animation::EmptyStateAnimation::default(); + let mut header = Self::placeholder_session_header_cell(&config); + history_cell::set_session_greeting(header.as_mut(), &empty_state_animation.greeting); + let active_cell = Some(header); let current_cwd = Some(config.cwd.to_path_buf()); let effective_service_tier = crate::service_tier_resolution::effective_service_tier( @@ -94,7 +97,7 @@ impl ChatWidget { pet_http_client.clone(), ); let mut widget = Self { - empty_state_animation: Default::default(), + empty_state_animation: std::cell::RefCell::new(empty_state_animation), cyber_policy_notice: Default::default(), app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index e170150768..3a3a68c304 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -203,7 +203,7 @@ impl ChatWidget { let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self .should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref()); - let session_info_cell = history_cell::new_session_info( + let mut session_info_cell = history_cell::new_session_info( &self.config, &self.local_settings, &model_for_header, @@ -214,6 +214,10 @@ impl ChatWidget { self.plan_type, show_fast_status, ); + history_cell::set_session_greeting( + &mut session_info_cell, + &self.empty_state_animation.borrow().greeting, + ); self.apply_session_info_cell(session_info_cell); } else if self .transcript diff --git a/codex-rs/tui/src/empty_state_animation.rs b/codex-rs/tui/src/empty_state_animation.rs index 192bd230dc..a51264be5f 100644 --- a/codex-rs/tui/src/empty_state_animation.rs +++ b/codex-rs/tui/src/empty_state_animation.rs @@ -1,22 +1,30 @@ //! The blossom welcome animation for onboarding, with a full-color final pose. -//! Visible time pauses while hidden; conversation lifecycle tracking is retained for the header. +//! Visible time pauses while hidden. Fresh conversations start settled and can replay on a click. mod geometry; +mod greetings; mod lighting; mod paths; mod policy; mod renderer; mod sequence; +use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; use std::time::Instant; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; use ratatui::buffer::Buffer; +use ratatui::layout::Position; use ratatui::layout::Rect; use ratatui::style::Color; +use crate::motion::MotionMode; use crate::terminal_palette; +pub(crate) use greetings::Greeting; use lighting::Lighting; pub(crate) use policy::Presentation; pub(crate) use policy::is_startup_cell; @@ -25,6 +33,8 @@ use renderer::MAX_ROWS; use renderer::Renderer; pub(crate) const FRAME_INTERVAL: Duration = Duration::from_millis(/*millis*/ 50); +// The preview's first visible pose (10.8s / 7.2s), also the start of an idle-screen replay. +const SETTLED_BLOSSOM: f64 = 0.5; #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum ComposerState { @@ -35,6 +45,9 @@ pub(crate) enum ComposerState { #[derive(Default)] pub(crate) struct EmptyStateAnimation { eligible: bool, + // Initialized once for fresh threads; never initialized for a resumed or forked thread. + // Headers retain the selection after the temporary blossom is dismissed. + pub(crate) greeting: Arc>, spin_elapsed: Duration, last_frame: Option, fade_elapsed: Duration, @@ -42,11 +55,19 @@ pub(crate) struct EmptyStateAnimation { opacity: f32, fade_from: f32, renderer: Option, + stage: Option, + replaying: bool, } impl EmptyStateAnimation { + pub(crate) fn is_eligible(&self) -> bool { + self.eligible + } + pub(crate) fn start_fresh(&mut self) { + self.cancel_replay(); self.eligible = true; + self.greeting.get_or_init(Greeting::choose); self.spin_elapsed = Duration::ZERO; self.last_frame = None; self.fade_elapsed = Duration::ZERO; @@ -54,8 +75,48 @@ impl EmptyStateAnimation { self.opacity = 1.0; } + /// Keep the provisional pose and phrase in headers already bound to the live thread. + pub(crate) fn continue_from(&mut self, source: &mut Self) { + let mut previous = std::mem::take(source); + if !previous.is_eligible() { + previous.start_fresh(); + } + if let Some(greeting) = previous.greeting.get() { + let _ = self.greeting.set(*greeting); + } + previous.greeting = Arc::clone(&self.greeting); + *self = previous; + } + pub(crate) fn dismiss(&mut self) { self.eligible = false; + self.cancel_replay(); + } + + pub(crate) fn cancel_replay(&mut self) { + self.stage = None; + self.replaying = false; + self.pause_clock(); + } + + /// Replay only on the drawn blossom; unrelated gestures keep their normal owner. + pub(crate) fn handle_mouse(&mut self, mouse: MouseEvent) -> bool { + let inside = self + .stage + .is_some_and(|stage| stage.contains(Position::new(mouse.column, mouse.row))) + && mouse.modifiers.is_empty(); + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) if inside => { + self.start_fresh(); + self.spin_elapsed = + Duration::from_secs_f64(SETTLED_BLOSSOM * sequence::LOOP_SECONDS); + self.opacity = sequence::STATIC_OPACITY; + self.static_mark = Some(true); + self.replaying = true; + true + } + _ => false, + } } /// Stop visible time at the last painted frame, including when no hidden frame was drawn. @@ -126,6 +187,55 @@ impl EmptyStateAnimation { + (self.opacity - self.fade_from) * sequence::progress(self.fade_elapsed, sequence::STATIC_FADE) as f32; } + self.paint_frame(area, buffer, phase, self.opacity); + (!finished && (!static_mark || settling)).then_some(FRAME_INTERVAL) + } + + /// Draw only in space the caller has cleared and owns. The centered blossom returns + /// on clear, without restarting or scheduling animation. + pub(crate) fn render_first_screen( + &mut self, + available: Rect, + buffer: &mut Buffer, + composer: Option, + motion: MotionMode, + ) -> Option { + const STAGE_ROWS: u16 = 21; + const MIN_STAGE_ROWS: u16 = 14; + let screen = buffer.area; + let width = screen.width.saturating_sub(/*rhs*/ 4).min(MAX_COLUMNS); + let height = width * STAGE_ROWS / MAX_COLUMNS; + if motion == MotionMode::Reduced + || composer != Some(ComposerState::Empty) + || height < MIN_STAGE_ROWS + || height > screen.height + { + self.cancel_replay(); + return None; + } + let stage = Rect::new( + screen.x + (screen.width - width) / 2, + screen.y + (screen.height - height) / 2, + width, + height, + ); + if stage.intersection(available) != stage { + self.cancel_replay(); + return None; + } + self.stage = Some(stage); + if self.replaying { + if let Some(delay) = self.render_in(stage, buffer, Presentation::Animated) { + return Some(delay); + } + // Paint the idle pose over the final full-color frame in the same update. + self.replaying = false; + } + self.paint_frame(stage, buffer, SETTLED_BLOSSOM, sequence::STATIC_OPACITY); + None + } + + fn paint_frame(&mut self, area: Rect, buffer: &mut Buffer, phase: f64, opacity: f32) { let background = terminal_palette::default_bg(); let color_level = if background.is_some() { terminal_palette::effective_stdout_color_level() @@ -150,7 +260,7 @@ impl EmptyStateAnimation { area.y + i as u16 / area.width, )]; target.set_char(char::from_u32(0x2800 + u32::from(cell.dots)).unwrap_or(' ')); - let color = crate::color::blend((r, g, b), background, self.opacity); + let color = crate::color::blend((r, g, b), background, opacity); let color = if color_level == terminal_palette::StdoutColorLevel::Ansi256 { // Four bits per channel bound the cache and avoid palette searches each frame. static COLORS: [OnceLock; 4096] = [const { OnceLock::new() }; 4096]; @@ -167,7 +277,7 @@ impl EmptyStateAnimation { }; target.set_fg(color); // Default-color terminals can only step between normal and dim intensity. - if self.opacity < 0.5 + if opacity < 0.5 && matches!( color_level, terminal_palette::StdoutColorLevel::Ansi16 @@ -177,7 +287,6 @@ impl EmptyStateAnimation { target.set_style(target.style().dim()); } } - (!finished && (!static_mark || settling)).then_some(FRAME_INTERVAL) } } diff --git a/codex-rs/tui/src/empty_state_animation/greetings.rs b/codex-rs/tui/src/empty_state_animation/greetings.rs new file mode 100644 index 0000000000..6dce8f65bd --- /dev/null +++ b/codex-rs/tui/src/empty_state_animation/greetings.rs @@ -0,0 +1,108 @@ +//! Startup phrases retained from the approved greeting list (PR #10248). +//! One selection is shared by the provisional, live, and committed session headers. + +use rand::Rng; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Greeting { + pub(crate) phrase: &'static str, +} + +impl Greeting { + pub(super) fn choose() -> Self { + Self { + phrase: GREETINGS[rand::rng().random_range(0..GREETINGS.len())], + } + } +} + +const GREETINGS: &[&str] = &[ + "Whoa, fancy meeting you here!", + "Look who’s at the keyboard.", + "Hello, you. Got an idea?", + "Nice to see you in these parts.", + "Pull up a prompt.", + "What brings you to this corner of the terminal?", + "Hey there. Big plans or little fixes?", + "What are we getting into today?", + "Got something you want to try?", + "What’s today’s little adventure?", + "Anything interesting on the docket?", + "Take your time. The cursor can wait.", + "A half-formed idea will do.", + "Come on in. There’s room for an idea.", + "You, me, and a blinking cursor.", + "It takes two to tango. What’s our first step?", + "You bring the idea. I’ll bring the brackets.", + "Your move, teammate.", + "What are we cooking up?", + "Shall we turn “what if” into something?", + "What’s the first thing on our napkin sketch?", + "Bring your unfinished thoughts.", + "You set the direction. We’ll work out the steps.", + "Ah, the terminal. A classic meeting spot.", + "Nice place you’ve got here. Very monospace.", + "Welcome to our little rectangle of possibility.", + "A cursor blinks. The plot thickens.", + "The prompt is yours.", + "Fancy a little quality terminal time?", + "How’s life between the brackets?", + "Shall we give this cursor a purpose?", + "A fresh prompt. An open question.", + "Your keyboard has entered the chat.", + "Hello, world. Hello, you.", + "Welcome to the blinking edge of possibility.", + "Any loose ends? Semicolons count.", + "Shall we make this terminal earn its keep?", + "Got an itch to fix a thing?", + "Show me the bit that’s being weird.", + "Greetings, fellow tinkerer.", + "Well, well, well. An idea approaches.", + "What’s today’s side quest?", + "Is this a plan day or a poke-around day?", + "Practical, peculiar, or a little of both?", + "Rough sketches welcome.", + "Shall we see where this goes?", + "All right. What have you got?", + "Back for another round?", + "Same terminal, new possibilities.", + "Welcome back. Familiar territory or a fresh adventure?", + "Follow the white cursor.", + "Welcome to the command line, Neo.", + "A glitch in the Matrix, or just a missing semicolon?", + "How deep does this codebase go?", + "Speak, friend, and enter a prompt.", + "One prompt to begin the journey.", + "May the source be with you.", + "These might be the bugs you’re looking for.", + "A new prompt awakens.", + "Forty-two is an answer. What’s the question?", + "It’s dangerous to code alone. Take a prompt.", + "The code must flow.", + "You rang? Metaphorically. You typed.", + "Hello again, carbon-based collaborator.", + "This looks like the start of a perfectly reasonable rabbit hole.", + "Shall we turn “huh?” into “aha!”?", + "Well, this terminal just got interesting.", + "The source is strong with this one.", + "There’s a perfectly good prompt with your name on it.", + "Here for a quick fix or the extended edition?", + "What’s the latest from your side of the keyboard?", + "Got a minute and a mildly unreasonable idea?", + "A hunch is a perfectly respectable starting point.", + "Hello again. What’s the plot this time?", + "Shall we make a little something out of nothing?", + "Welcome to the neighborhood. Lots of characters here.", + "Nice terminal. Does it come in widescreen?", + "Your cursor called. It wants a plot.", + "Shall we put some verbs after that cursor?", + "Welcome. There’s no dress code, just code.", + "This terminal has excellent conversational potential.", + "Got an idea that won’t stay on the napkin?", + "What are we building in this episode?", + "Bring a question. Bonus points if it’s a weird one.", + "What are we poking with a metaphorical stick?", + "Shall we make the thing that makes the other thing easier?", + "Welcome to the part where the idea gets interesting.", + "A long time ago, in a directory not so far away…", +]; diff --git a/codex-rs/tui/src/empty_state_animation/policy.rs b/codex-rs/tui/src/empty_state_animation/policy.rs index 63b3c9ae9c..2a02496079 100644 --- a/codex-rs/tui/src/empty_state_animation/policy.rs +++ b/codex-rs/tui/src/empty_state_animation/policy.rs @@ -1,10 +1,8 @@ //! Positive eligibility for the fresh-conversation decoration. //! Unknown history cell types are activity, even when they render no visible text. -use super::ComposerState; use crate::history_cell; use crate::history_cell::HistoryCell; -use crate::motion::MotionMode; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Presentation { @@ -13,22 +11,6 @@ pub(crate) enum Presentation { Faded, } -impl Presentation { - /// Only an explicitly available ordinary composer may display a fresh-thread logo. - pub(crate) fn for_composer( - composer: Option, - motion: MotionMode, - focused: bool, - ) -> Self { - match (composer, motion, focused) { - (None, _, _) | (_, MotionMode::Reduced, _) => Self::Hidden, - (Some(ComposerState::Empty), MotionMode::Animated, true) => Self::Animated, - (Some(ComposerState::Empty), MotionMode::Animated, false) - | (Some(ComposerState::Draft), MotionMode::Animated, _) => Self::Faded, - } - } -} - /// Startup metadata is the entire allowlist; new content types opt out by default. pub(crate) fn is_startup_cell(cell: &dyn HistoryCell) -> bool { cell.as_any().is::() diff --git a/codex-rs/tui/src/empty_state_animation_tests.rs b/codex-rs/tui/src/empty_state_animation_tests.rs index cfa12959ba..2769ea9ba0 100644 --- a/codex-rs/tui/src/empty_state_animation_tests.rs +++ b/codex-rs/tui/src/empty_state_animation_tests.rs @@ -1,13 +1,159 @@ //! Logo visibility, theme contrast, placement, and redraw lifecycle tests. use super::*; +use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; -impl EmptyStateAnimation { - // App integration tests inspect lifecycle eligibility without advancing the render clock. - pub(crate) fn is_eligible_for_test(&self) -> bool { - self.eligible +#[test] +fn first_screen_stage_ignores_notices_and_hides_instead_of_shrinking() { + let mut animation = EmptyStateAnimation::default(); + animation.start_fresh(); + let screen = Rect::new( + /*x*/ 7, /*y*/ 3, /*width*/ 80, /*height*/ 44, + ); + let mut buffer = Buffer::empty(screen); + for header_rows in [5, 7, 11] { + buffer.reset(); + let free = Rect::new( + screen.x, + screen.y + header_rows, + screen.width, + screen.height - header_rows - 3, + ); + assert_eq!( + animation.render_first_screen( + free, + &mut buffer, + Some(ComposerState::Empty), + MotionMode::Animated + ), + None + ); + assert_eq!( + animation.stage, + Some(Rect::new( + /*x*/ 17, /*y*/ 14, /*width*/ 60, /*height*/ 21 + )) + ); } + // Content which reaches the center must win; it must not move the stage down. + let free = Rect::new(screen.x, /*y*/ 20, screen.width, /*height*/ 24); + animation.render_first_screen( + free, + &mut buffer, + Some(ComposerState::Empty), + MotionMode::Animated, + ); + assert_eq!(animation.stage, None); + // Both sides of the minimum size, and a terminal that is too short for the full logo. + for (width, height, expected) in [(44, 36, true), (43, 36, false), (80, 20, false)] { + let screen = Rect::new(/*x*/ 7, /*y*/ 3, width, height); + let mut buffer = Buffer::empty(screen); + animation.render_first_screen( + screen, + &mut buffer, + Some(ComposerState::Empty), + MotionMode::Animated, + ); + assert_eq!(animation.stage.is_some(), expected); + } +} + +#[test] +fn first_screen_single_click_replays_the_visible_blossom() { + let mut animation = EmptyStateAnimation::default(); + animation.start_fresh(); + let screen = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 44, + ); + let mut buffer = Buffer::empty(screen); + let click = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 40, + row: 22, + modifiers: KeyModifiers::NONE, + }; + let draw = |animation: &mut EmptyStateAnimation, buffer: &mut Buffer, composer| { + buffer.reset(); + animation.render_first_screen(screen, buffer, composer, MotionMode::Animated) + }; + assert_eq!( + draw(&mut animation, &mut buffer, Some(ComposerState::Empty)), + None + ); + let static_frame = buffer.clone(); + for other in [ + MouseEvent { + column: 0, + row: 0, + ..click + }, + MouseEvent { + modifiers: KeyModifiers::CONTROL, + ..click + }, + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + ..click + }, + ] { + assert!(!animation.handle_mouse(other)); + } + assert_eq!( + draw(&mut animation, &mut buffer, Some(ComposerState::Empty)), + None + ); + assert_eq!(buffer, static_frame); + assert!(animation.handle_mouse(click)); + // Release belongs to the transcript if it started a selection outside the logo. + assert!(!animation.handle_mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + ..click + })); + assert_eq!( + draw(&mut animation, &mut buffer, Some(ComposerState::Empty)), + Some(FRAME_INTERVAL) + ); + assert_eq!(buffer, static_frame); + let started = animation.last_frame.unwrap(); + let stage = animation.stage.unwrap(); + buffer.reset(); + assert_eq!( + animation.render_in_at( + stage, + &mut buffer, + Presentation::Animated, + started + sequence::STATIC_FADE, + ), + Some(FRAME_INTERVAL) + ); + assert_ne!(buffer, static_frame); + assert!(animation.handle_mouse(click)); + assert_eq!( + draw(&mut animation, &mut buffer, Some(ComposerState::Empty)), + Some(FRAME_INTERVAL) + ); + assert_eq!(buffer, static_frame); + assert_eq!( + draw(&mut animation, &mut buffer, Some(ComposerState::Draft)), + None + ); + assert!(!animation.handle_mouse(click)); + assert_eq!( + draw(&mut animation, &mut buffer, Some(ComposerState::Empty)), + None + ); + assert_eq!(buffer, static_frame); + // Disabling motion removes both the logo and its click target. + buffer.reset(); + animation.render_first_screen( + screen, + &mut buffer, + Some(ComposerState::Empty), + MotionMode::Reduced, + ); + assert_eq!(buffer, Buffer::empty(screen)); + assert!(!animation.handle_mouse(click)); } #[test] diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index 3d9c174856..e749c027fc 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -1,6 +1,10 @@ //! Session headers, onboarding guidance, and transcript cards. +use std::sync::Arc; +use std::sync::OnceLock; + use super::*; +use crate::empty_state_animation::Greeting; use crate::line_truncation::line_width; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::style::accent_color; @@ -121,6 +125,46 @@ impl HistoryCell for SessionNoticeCell { #[derive(Debug)] pub struct SessionInfoCell(CompositeHistoryCell); +/// Bind provisional and configured banners to the thread's chosen greeting. +pub(crate) fn set_session_greeting(cell: &mut dyn HistoryCell, greeting: &Arc>) { + if let Some(header) = cell.as_any_mut().downcast_mut::() { + header.greeting = Arc::clone(greeting); + } else if let Some(info) = cell.as_any_mut().downcast_mut::() { + for part in &mut info.0.parts { + set_session_greeting(part.as_mut(), greeting); + } + } +} + +/// Fullscreen transcript presentation omits tips; scrollback retains the original cells. +pub(crate) fn fullscreen_session_lines( + cell: &dyn HistoryCell, + width: u16, + detailed: bool, + mode: HistoryRenderMode, +) -> Vec { + if let Some(info) = cell.as_any().downcast_ref::() { + let mut lines = Vec::new(); + for part in &info.0.parts { + if part.as_any().is::() { + continue; + } + let part_lines = fullscreen_session_lines(part.as_ref(), width, detailed, mode); + if !part_lines.is_empty() { + if !lines.is_empty() { + lines.push(HyperlinkLine::from("")); + } + lines.extend(part_lines); + } + } + lines + } else if detailed || mode == HistoryRenderMode::Rich { + cell.retained_hyperlink_lines(width, detailed) + } else { + cell.display_hyperlink_lines_for_mode(width, mode) + } +} + impl HistoryCell for SessionInfoCell { fn compact_hyperlink_lines(&self, width: u16) -> Vec { self.0.compact_hyperlink_lines(width) @@ -269,6 +313,7 @@ pub(crate) struct SessionHeaderHistoryCell { show_fast_status: bool, directory: PathBuf, yolo_mode: bool, + greeting: Arc>, } impl SessionHeaderHistoryCell { @@ -305,6 +350,7 @@ impl SessionHeaderHistoryCell { show_fast_status, directory, yolo_mode: false, + greeting: Default::default(), } } @@ -348,7 +394,50 @@ impl SessionHeaderHistoryCell { } impl HistoryCell for SessionHeaderHistoryCell { + fn compact_hyperlink_lines(&self, width: u16) -> Vec { + if self.greeting.get().is_none() { + return self.display_hyperlink_lines(width); + } + let width = usize::from(width); + let mut lines = vec![ + Line::default(), + Line::from(vec![ + " ".into(), + ">_ ".fg(accent_color()), + "OpenAI Codex".bold(), + format!(" (v{})", self.version).dim(), + ]), + Line::from(vec![ + " ".into(), + self.format_directory(Some(width.saturating_sub(/*rhs*/ 5))) + .dim(), + ]), + ]; + if self.yolo_mode { + lines.push(Line::from(vec![ + " permissions: ".dim(), + "YOLO mode".magenta().bold(), + ])); + } + if let Some(greeting) = self.greeting.get() { + // The tip/help that follows has its own normal composite separator. + lines.extend([ + Line::default(), + Line::from(vec![" ".into(), greeting.phrase.fg(accent_color())]), + ]); + } + plain_hyperlink_lines( + lines + .into_iter() + .map(|line| truncate_line_with_ellipsis_if_overflow(line, width)) + .collect(), + ) + } + fn display_lines(&self, width: u16) -> Vec> { + if self.greeting.get().is_some() { + return visible_lines(self.compact_hyperlink_lines(width)); + } let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else { return Vec::new(); }; @@ -428,6 +517,12 @@ impl HistoryCell for SessionHeaderHistoryCell { } fn raw_lines(&self) -> Vec> { + if self.greeting.get().is_some() { + return visible_lines(self.compact_hyperlink_lines(u16::MAX)) + .into_iter() + .map(|line| Line::from(line.to_string())) + .collect(); + } let mut lines = vec![ Line::from(format!("OpenAI Codex (v{})", self.version)), Line::from(format!( diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__layout__tests__owned_startup_layout.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__layout__tests__owned_startup_layout.snap index 9de37490a3..957e7539d5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__layout__tests__owned_startup_layout.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__layout__tests__owned_startup_layout.snap @@ -1,14 +1,14 @@ --- source: tui/src/startup_draft_layout_tests.rs -expression: startup_layout +expression: "format!(\"cursor={:?}\\n{frame}\",\nlayout.cursor_pos(area)).replace(crate::version::CODEX_CLI_VERSION,\n\"\")" --- cursor=Some((13, 12)) -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. + 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 index 32f6de9153..fb019c0e58 100644 --- 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 @@ -1,27 +1,26 @@ --- source: tui/src/startup_draft_tests.rs +assertion_line: 285 expression: "snapshots.join(\"\\n---\\n\")" --- resume lookup fallback: before: -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. Resuming session… › draft while loading after: -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. › draft while loading @@ -30,24 +29,22 @@ after: --- fork lookup fallback: before: -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. Forking session… › draft while loading after: -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. › draft while loading @@ -58,12 +55,11 @@ resume picker cancellation: before: hidden while picker owns input after: -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. › Ask Codex to do anything @@ -74,12 +70,11 @@ fork picker cancellation: before: hidden while picker owns input after: -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. › Ask Codex to do anything 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 9195af7637..3eb081d7d4 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 @@ -1,14 +1,14 @@ --- source: tui/src/startup_draft_tests.rs +assertion_line: 148 expression: "snapshots.join(\"\\n---\\n\")" --- empty (48 columns): -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. › Ask Codex to do anything @@ -16,12 +16,11 @@ empty (48 columns): ? for shortcuts --- resuming (48 columns): -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. Resuming session… › Ask Codex to do anything @@ -29,12 +28,11 @@ resuming (48 columns): ? for shortcuts --- forking (48 columns): -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. Forking session… › draft while loading @@ -42,12 +40,11 @@ forking (48 columns): --- multiline (48 columns): -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. › first startup line @@ -56,12 +53,11 @@ multiline (48 columns): --- narrow (18 columns): -╭────────────────╮ -│ >_ OpenAI Cod… │ -│ │ -│ model: lo… │ -│ directory: …ng │ -╰────────────────╯ + + >_ OpenAI Codex… + loading + + Pull up a promp… › first startup diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_onboarding_transition.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_onboarding_transition.snap index 6284b0715e..681cfd873b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_onboarding_transition.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_onboarding_transition.snap @@ -1,16 +1,16 @@ --- source: tui/src/startup_draft_tests.rs +assertion_line: 802 expression: frames --- before onboarding: hidden (0x0 viewport) --- after onboarding: -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. › Ask Codex to do anything diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__terminal_app_ssh_startup.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__terminal_app_ssh_startup.snap index 5d25a1b9ff..2ec0e1e641 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__terminal_app_ssh_startup.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__terminal_app_ssh_startup.snap @@ -1,15 +1,15 @@ --- source: tui/src/startup_draft_tests.rs -assertion_line: 162 +assertion_line: 194 expression: "frames.join(\"\\n---\\n\").replace(crate::version::CODEX_CLI_VERSION, \"\")" --- terminal_app_over_ssh=false, owned=true -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. + @@ -22,12 +22,11 @@ terminal_app_over_ssh=false, owned=true ? for shortcuts --- terminal_app_over_ssh=true, owned=false -╭───────────────────────────────────────╮ -│ >_ OpenAI Codex (v) │ -│ │ -│ model: loading /model to change │ -│ directory: loading │ -╰───────────────────────────────────────╯ + + >_ OpenAI Codex (v) + loading + + Pull up a prompt. › Ask Codex to do anything diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index 6fd83d8b3d..dd6cda869c 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -59,6 +59,7 @@ pub(crate) struct StartupScreen { pub(crate) use_alt_screen: bool, pub(crate) transcript_mode: crate::transcript_mode::TranscriptMode, pub(crate) status_line_enabled: bool, + pub(crate) welcome_motion: crate::motion::MotionMode, pub(crate) keymap: RuntimeKeymap, pub(crate) disable_paste_burst: bool, } @@ -103,6 +104,8 @@ pub(crate) struct StartupDraft { /// Keeps terminal input responsive and carries one submission intent into the live session. pub(crate) struct StartupDraftPump { header: Box, + pub(crate) blossom: std::cell::RefCell, + motion: crate::motion::MotionMode, bottom_pane: BottomPane, events: Pin + Send>>, app_event_rx: UnboundedReceiver, @@ -135,6 +138,7 @@ impl StartupDraft { tui.terminal_app_over_ssh = initialized_terminal.terminal_app_over_ssh; tui.set_alt_screen_enabled(screen.use_alt_screen); let mut pump = StartupDraftPump::new(&tui, initial_screen, session_action); + pump.motion = screen.welcome_motion; pump.bottom_pane .set_status_line_enabled(screen.status_line_enabled); pump.bottom_pane.set_keymap_bindings(&screen.keymap); @@ -193,8 +197,19 @@ impl StartupDraftPump { session_action: StartupDraftSessionAction, ) -> Self { let (app_event_tx, app_event_rx) = unbounded_channel(); + let mut blossom = crate::empty_state_animation::EmptyStateAnimation::default(); + if matches!( + session_action, + StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter + ) { + blossom.start_fresh(); + } + let mut header = startup_session_header(/*config*/ None); + history_cell::set_session_greeting(header.as_mut(), &blossom.greeting); Self { - header: startup_session_header(/*config*/ None), + header, + blossom: std::cell::RefCell::new(blossom), + motion: crate::system_motion::mode(), bottom_pane: startup_draft_bottom_pane( AppEventSender::new(app_event_tx), tui.frame_requester(), @@ -245,7 +260,11 @@ impl StartupDraftPump { } self.configured_cwd = Some(config.cwd.to_path_buf()); let local_settings = crate::local_settings::LocalSettings::from(config); + self.motion = crate::motion::MotionMode::from_animations_enabled( + local_settings.tui.animations && local_settings.tui.effects.welcome, + ); self.header = startup_session_header(Some(config)); + history_cell::set_session_greeting(self.header.as_mut(), &self.blossom.borrow().greeting); self.bottom_pane.set_status_line_enabled( local_settings .tui @@ -307,6 +326,11 @@ impl StartupDraftPump { } self.resolved_selection = Some(session_selection.clone()); self.session_action = session_action; + if matches!(session_selection, SessionSelection::StartFresh) + && !self.blossom.borrow().is_eligible() + { + self.blossom.borrow_mut().start_fresh(); + } if self.initial_screen == StartupDraftInitialScreen::Composer { self.draw(tui, tui.terminal.last_known_screen_size)?; } @@ -417,8 +441,7 @@ impl StartupDraftPump { } self.bottom_pane.pre_draw_tick(); let owned = tui.is_owned_screen(); - let owned_layout = - layout::OwnedStartupLayout::new(&self.header, &self.bottom_pane, self.session_action); + let owned_layout = layout::OwnedStartupLayout::new(self); let renderable = if owned { RenderableItem::Borrowed(&owned_layout) } else { @@ -437,6 +460,9 @@ impl StartupDraftPump { frame.set_cursor_position((x, y)); } })?; + if let Some(delay) = owned_layout.next_frame.get() { + tui.frame_requester().schedule_frame_in(delay); + } Ok(()) } } diff --git a/codex-rs/tui/src/startup_draft_input.rs b/codex-rs/tui/src/startup_draft_input.rs index 3d398139f1..491b99c7fc 100644 --- a/codex-rs/tui/src/startup_draft_input.rs +++ b/codex-rs/tui/src/startup_draft_input.rs @@ -161,9 +161,26 @@ impl StartupDraftPump { TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {} TuiEvent::FocusLost => { self.key_chord_matcher.cancel(); + self.blossom.borrow_mut().cancel_replay(); return Ok(()); } - TuiEvent::Mouse(_) => return Ok(()), + TuiEvent::Mouse(mouse) => { + if !tui.is_owned_screen() + || self.initial_screen != StartupDraftInitialScreen::Composer + || !matches!( + self.session_action, + super::StartupDraftSessionAction::New + | super::StartupDraftSessionAction::NewFromCommandCenter + ) + { + return Ok(()); + } + // Size, composer, and modal ownership must be current before hit testing. + self.draw(tui, screen_size)?; + if !self.blossom.borrow_mut().handle_mouse(mouse) { + return Ok(()); + } + } } crate::startup_recovery::remember(self.bottom_pane.composer_recovery_snapshot()); if self.initial_screen == StartupDraftInitialScreen::Composer { diff --git a/codex-rs/tui/src/startup_draft_layout.rs b/codex-rs/tui/src/startup_draft_layout.rs index ed64e73710..8b77ee5f59 100644 --- a/codex-rs/tui/src/startup_draft_layout.rs +++ b/codex-rs/tui/src/startup_draft_layout.rs @@ -1,38 +1,54 @@ //! First-frame owned layout: the banner stays at the top and the normal composer at the bottom. //! Measurement, paint, and cursor placement use the same bottom rectangle, including both footers. +//! Only a new conversation paints decoration in the unused area, so it cannot enter scrollback. use crossterm::cursor::SetCursorStyle; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::style::Style; use ratatui::style::Stylize; +use ratatui::widgets::Widget; +use std::cell::Cell; +use std::time::Duration; +use super::StartupDraftPump; use super::StartupDraftSessionAction; -use crate::bottom_pane::BottomPane; use crate::bottom_pane::CommandPopupPlacement; use crate::bottom_pane::ComposerRenderOptions; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::HyperlinkParagraph; pub(super) struct OwnedStartupLayout<'a> { - header: &'a dyn Renderable, + pump: &'a StartupDraftPump, bottom: RenderableItem<'a>, - session_action: StartupDraftSessionAction, + pub(super) next_frame: Cell>, } impl<'a> OwnedStartupLayout<'a> { - pub(super) fn new( - header: &'a dyn Renderable, - bottom_pane: &'a BottomPane, - session_action: StartupDraftSessionAction, - ) -> Self { + pub(super) fn new(pump: &'a StartupDraftPump) -> Self { Self { - header, - bottom: bottom_pane.as_renderable_with_options(ComposerRenderOptions { - separate_status_line: true, - command_popup_placement: CommandPopupPlacement::Overlay, - ..ComposerRenderOptions::default() - }), - session_action, + pump, + next_frame: Cell::new(/*value*/ None), + bottom: pump + .bottom_pane + .as_renderable_with_options(ComposerRenderOptions { + separate_status_line: true, + command_popup_placement: CommandPopupPlacement::Overlay, + ..ComposerRenderOptions::default() + }), + } + } + + fn header_lines(&self, width: u16) -> Vec { + match self.pump.session_action { + StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => { + self.pump.header.compact_hyperlink_lines(width) + } + StartupDraftSessionAction::Resume | StartupDraftSessionAction::Fork => { + self.pump.header.display_hyperlink_lines(width) + } } } @@ -49,16 +65,31 @@ impl<'a> OwnedStartupLayout<'a> { impl Renderable for OwnedStartupLayout<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { let bottom = self.bottom_area(area); + let lines = self.header_lines(area.width); + let paragraph = HyperlinkParagraph::new(&lines, Style::default()); let header = Rect { - height: self - .header - .desired_height(area.width) + height: u16::try_from(paragraph.line_count(area.width)) + .unwrap_or(u16::MAX) .min(bottom.y.saturating_sub(area.y)), ..area }; - self.header.render(header, buf); - let message = match self.session_action { + paragraph.render(header, buf); + let message = match self.pump.session_action { StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => { + let composer = (!self.pump.submission_pending) + .then(|| self.pump.bottom_pane.empty_state_composer()) + .flatten(); + self.next_frame + .set(self.pump.blossom.borrow_mut().render_first_screen( + Rect { + y: header.bottom(), + height: bottom.y.saturating_sub(header.bottom()), + ..area + }, + buf, + composer, + self.pump.motion, + )); None } StartupDraftSessionAction::Resume => Some(" Resuming session…"), @@ -80,8 +111,9 @@ impl Renderable for OwnedStartupLayout<'_> { } fn desired_height(&self, width: u16) -> u16 { - self.header - .desired_height(width) + let lines = self.header_lines(width); + u16::try_from(HyperlinkParagraph::new(&lines, Style::default()).line_count(width)) + .unwrap_or(u16::MAX) .saturating_add(self.bottom.desired_height(width)) } diff --git a/codex-rs/tui/src/startup_draft_layout_tests.rs b/codex-rs/tui/src/startup_draft_layout_tests.rs index b32d9f1e08..1644ad3103 100644 --- a/codex-rs/tui/src/startup_draft_layout_tests.rs +++ b/codex-rs/tui/src/startup_draft_layout_tests.rs @@ -12,11 +12,7 @@ fn owned_startup_keeps_the_live_bottom_geometry() { ("Waiting for startup".into(), String::new()), ("esc".into(), "cancel".into()), ])); - let layout = OwnedStartupLayout::new( - &pump.header, - &pump.bottom_pane, - StartupDraftSessionAction::New, - ); + let layout = OwnedStartupLayout::new(&pump); let area = Rect::new( /*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 16, ); @@ -36,3 +32,106 @@ fn owned_startup_keeps_the_live_bottom_geometry() { .replace(crate::version::CODEX_CLI_VERSION, "") ); } + +#[test] +fn new_startup_decoration_tracks_draft_and_session_action() { + let mut pump = crate::startup_draft::tests::quiet_startup_test_pump(); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 32, + ); + let mut blossom_visible = Vec::new(); + for (draft, action, pending) in [ + ("", StartupDraftSessionAction::New, false), + (" ", StartupDraftSessionAction::New, false), + ("", StartupDraftSessionAction::New, false), + ("", StartupDraftSessionAction::New, true), + ("", StartupDraftSessionAction::Resume, false), + ("", StartupDraftSessionAction::Fork, false), + ("", StartupDraftSessionAction::NewFromCommandCenter, false), + ] { + pump.session_action = action; + pump.submission_pending = pending; + pump.bottom_pane + .set_composer_text(draft.into(), Vec::new(), Vec::new()); + let layout = OwnedStartupLayout::new(&pump); + let mut buffer = Buffer::empty(area); + layout.render(area, &mut buffer); + blossom_visible.push(buffer.content.iter().any(|cell| { + cell.symbol() + .chars() + .any(|ch| ('\u{2801}'..='\u{28ff}').contains(&ch)) + })); + } + assert_eq!( + blossom_visible, + [true, false, true, false, false, false, true] + ); +} + +#[tokio::test] +async fn configured_welcome_opt_out_disables_blossom_and_clicks() -> anyhow::Result<()> { + use clap::Parser; + use codex_config::LoaderOverrides; + use crossterm::event::KeyModifiers; + use crossterm::event::MouseButton; + use crossterm::event::MouseEvent; + use crossterm::event::MouseEventKind; + let codex_home = tempfile::tempdir()?; + let cli = crate::Cli::try_parse_from(["codex"])?; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 44, + ); + let click = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 40, + row: 22, + modifiers: KeyModifiers::NONE, + }; + for (settings, enabled) in [ + ("", true), + ("[tui]\nanimations = false\n", false), + ("[tui.effects]\nwelcome = false\n", false), + ( + "[tui]\nanimations = false\n[tui.effects]\nwelcome = false\n", + false, + ), + ] { + std::fs::write(codex_home.path().join("config.toml"), settings)?; + let presentation = crate::startup_presentation::load( + &cli, + codex_home.path(), + LoaderOverrides { + ignore_project_config: true, + ..LoaderOverrides::without_managed_config_for_tests() + }, + Vec::new(), + /*config_cwd*/ None, + ) + .await?; + let config = crate::legacy_core::config::ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + let mut pump = crate::startup_draft::tests::quiet_startup_test_pump(); + // The provisional frame uses the bootstrap value; subsequent frames use effective config. + pump.motion = presentation.screen.welcome_motion; + for effective in [false, true] { + if effective { + pump.apply_config(&config); + } + let mut buffer = Buffer::empty(area); + OwnedStartupLayout::new(&pump).render(area, &mut buffer); + let visible = buffer.content.iter().any(|cell| { + cell.symbol() + .chars() + .any(|ch| ('\u{2801}'..='\u{28ff}').contains(&ch)) + }); + assert_eq!( + visible, enabled, + "settings: {settings}, effective: {effective}" + ); + assert_eq!(pump.blossom.borrow_mut().handle_mouse(click), enabled); + } + } + Ok(()) +} diff --git a/codex-rs/tui/src/startup_draft_tests.rs b/codex-rs/tui/src/startup_draft_tests.rs index ffb7e6f54b..d89b5d071e 100644 --- a/codex-rs/tui/src/startup_draft_tests.rs +++ b/codex-rs/tui/src/startup_draft_tests.rs @@ -28,8 +28,20 @@ where I: Iterator + Send + 'static, { let (tx, rx) = unbounded_channel(); + let mut blossom = crate::empty_state_animation::EmptyStateAnimation::default(); + blossom + .greeting + .set(crate::empty_state_animation::Greeting { + phrase: "Pull up a prompt.", + }) + .expect("initial greeting"); + blossom.start_fresh(); + let mut header = startup_session_header(/*config*/ None); + crate::history_cell::set_session_greeting(header.as_mut(), &blossom.greeting); StartupDraftPump { - header: startup_session_header(/*config*/ None), + header, + blossom: std::cell::RefCell::new(blossom), + motion: crate::motion::MotionMode::Animated, bottom_pane: startup_draft_bottom_pane( AppEventSender::new(tx), FrameRequester::test_dummy(), @@ -138,11 +150,7 @@ fn startup_draft_renders_full_empty_and_multiline_composer_frames() { #[test] fn terminal_app_ssh_fallback_renders_inline_startup() { let pump = startup_test_pump(std::iter::empty()); - let owned_layout = super::layout::OwnedStartupLayout::new( - &pump.header, - &pump.bottom_pane, - StartupDraftSessionAction::New, - ); + let owned_layout = super::layout::OwnedStartupLayout::new(&pump); let mut frames = Vec::new(); for terminal_app_over_ssh in [false, true] { let owned = crate::determine_alt_screen_mode( @@ -168,6 +176,12 @@ fn terminal_app_ssh_fallback_renders_inline_startup() { let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); let mut buffer = Buffer::empty(area); renderable.render(area, &mut buffer); + assert!( + buffer + .content + .iter() + .any(|cell| { cell.symbol() == ">" && cell.fg == crate::style::accent_color() }) + ); let frame = (0..height) .map(|y| { (0..width) @@ -243,6 +257,7 @@ async fn startup_draft_clears_loading_status_when_starting_fresh() { let mut pump = startup_test_pump(std::iter::empty()); pump.initial_screen = initial_screen; pump.session_action = session_action; + pump.blossom.borrow_mut().dismiss(); if initial_screen == StartupDraftInitialScreen::Composer { pump.bottom_pane.insert_str("draft while loading"); } @@ -257,6 +272,7 @@ async fn startup_draft_clears_loading_status_when_starting_fresh() { pump.update_session_selection(&mut tui, &SessionSelection::StartFresh) .expect("clear the loading status after a fresh-session selection"); + assert!(pump.blossom.borrow().is_eligible()); if initial_screen == StartupDraftInitialScreen::SessionPicker { assert!(tui.terminal.viewport_area.is_empty()); pump.show(&mut tui) @@ -291,21 +307,15 @@ async fn startup_draft_hydrates_its_header_without_moving_the_composer() { 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), - Some("directory: loading".to_string()) - ); + assert_eq!(pump.header.raw_lines()[2].to_string().trim(), "loading"); pump.apply_config(&config); - let expected_directory = format!( - "directory: {}", - crate::history_cell::SessionHeaderHistoryCell::format_directory_inner( - config.cwd.as_path(), - /*max_width*/ None, - ) + let expected_directory = crate::history_cell::SessionHeaderHistoryCell::format_directory_inner( + config.cwd.as_path(), + /*max_width*/ None, ); assert_eq!( - pump.header.raw_lines().last().map(ToString::to_string), - Some(expected_directory) + pump.header.raw_lines()[2].to_string().trim(), + expected_directory ); assert_eq!( startup_draft_renderable(&pump.header, &pump.bottom_pane, pump.session_action) diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 1305e61c6c..847821d35f 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -523,12 +523,10 @@ pub(super) async fn run_main_inner( daemon_features.retain(|_, enabled| *enabled); let mut managed_daemon = false; if auto_start_daemon && daemon_exclusion.is_none() { - startup_draft.flush_pending_events().await?; let output = startup_draft - .tui_mut() - .with_restored(|| async { - // Package installation may print progress; keep ordinary Ctrl+C handling. - crossterm::terminal::disable_raw_mode()?; + .run_until(async { + // Daemon startup needs no terminal input. Keep the composer visible and + // responsive while it checks the running server or prepares an installation. let result = codex_app_server_daemon::start_with_features(&daemon_features).await; daemon_telemetry::record_start(&config, &result).await; match result { @@ -543,7 +541,7 @@ pub(super) async fn run_main_inner( ))), } }) - .await?; + .await??; if let Some(output) = output { managed_daemon = output.backend.is_some(); app_server_target = AppServerTarget::LocalDaemon { diff --git a/codex-rs/tui/src/startup_presentation.rs b/codex-rs/tui/src/startup_presentation.rs index d718b0cd71..4c431d5227 100644 --- a/codex-rs/tui/src/startup_presentation.rs +++ b/codex-rs/tui/src/startup_presentation.rs @@ -81,6 +81,13 @@ pub(super) async fn load( .disable_paste_burst .or(config_toml.disable_paste_burst) .unwrap_or(/*default*/ false); + let welcome_motion = crate::motion::MotionMode::from_animations_enabled( + config_toml + .tui + .as_ref() + .is_none_or(|tui| tui.animations && tui.effects.welcome) + && crate::system_motion::mode() == crate::motion::MotionMode::Animated, + ); Ok(StartupPresentation { bootstrap_config, config_cwd, @@ -88,6 +95,7 @@ pub(super) async fn load( use_alt_screen, transcript_mode, status_line_enabled, + welcome_motion, keymap, disable_paste_burst, }, diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index d6a25096e6..db58338e29 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -149,6 +149,19 @@ impl Default for TranscriptView { } impl TranscriptView { + /// Rows left after the last render, including all startup notices and live entries. + /// Callers can paint temporary UI here without changing selection or saved history. + pub(crate) fn remaining_area(&self) -> Rect { + let used = u16::try_from(self.visible.len()) + .unwrap_or(u16::MAX) + .min(self.area.height); + Rect { + y: self.area.y + used, + height: self.area.height - used, + ..self.area + } + } + pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer, cells: &[Arc]) { self.composer_tip = None; self.cache.begin_frame(); @@ -543,6 +556,15 @@ impl TranscriptView { .layout(cells, index) .map_or(/*default*/ 0, |l| l.row_count()); } + // At the beginning, hidden entries are not preceding content. Keep the first + // visible entry at the same position whether it is live or committed. + if index == 0 + && remaining >= row + && let Some(first) = self.next_nonempty(cells, index) + && let Some(layout) = self.layout(cells, first) + { + return (first, usize::from(layout.separated)); + } return (index, row.saturating_sub(remaining)); } let mut remaining = row.saturating_add(rows as usize); diff --git a/codex-rs/tui/src/transcript_view/layout.rs b/codex-rs/tui/src/transcript_view/layout.rs index d9640bf586..e2eb210346 100644 --- a/codex-rs/tui/src/transcript_view/layout.rs +++ b/codex-rs/tui/src/transcript_view/layout.rs @@ -156,10 +156,16 @@ impl TranscriptView { width, expanded, ) - } else if detailed || mode == HistoryRenderMode::Rich { - TextLayout::new(cell.retained_hyperlink_lines(width, detailed), width) } else { - TextLayout::new(cell.display_hyperlink_lines_for_mode(width, mode), width) + TextLayout::new( + crate::history_cell::fullscreen_session_lines( + cell.as_ref(), + width, + detailed, + mode, + ), + width, + ) } })) } diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs index db27e05410..95acea4df6 100644 --- a/codex-rs/tui/src/transcript_view/selection.rs +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -319,15 +319,19 @@ impl TranscriptView { Some(pending.follow && completion.1 == Ok(crate::clipboard_copy::CopyStatus::Confirmed)) } - /// End the pointer gesture without discarding selected text when input ownership changes. - pub(crate) fn end_drag(&mut self) { - self.follow_control = Default::default(); - if !self.has_selection_range() + /// A stationary click begun at Latest still belongs to the fresh screen until it selects text. + pub(crate) fn has_pending_latest_selection(&self) -> bool { + !self.has_selection_range() && self .selection .as_ref() .is_some_and(|selection| selection.dragging && selection.resume_on_empty) - { + } + + /// End the pointer gesture without discarding selected text when input ownership changes. + pub(crate) fn end_drag(&mut self) { + self.follow_control = Default::default(); + if self.has_pending_latest_selection() { self.position = Position::Latest; self.selection = None; self.release_live_reading(); diff --git a/codex-rs/tui/src/transcript_view_tests.rs b/codex-rs/tui/src/transcript_view_tests.rs index c9b68b0dc5..b1e1940847 100644 --- a/codex-rs/tui/src/transcript_view_tests.rs +++ b/codex-rs/tui/src/transcript_view_tests.rs @@ -63,6 +63,60 @@ pub(super) fn text(buffer: &Buffer) -> String { .join("\n") } +#[test] +fn startup_warning_keeps_first_visible_header_in_place() { + let header: Arc = + Arc::new(crate::history_cell::SessionHeaderHistoryCell::new( + "gpt-test".into(), + /*reasoning_effort*/ None, + /*show_fast_status*/ false, + crate::test_support::test_path_buf("/project"), + "test", + )); + let mut view = TranscriptView::default(); + view.sync_live_tail(/*width*/ 80, /*key*/ None, |width| { + Some(header.compact_hyperlink_lines(width)) + }); + let mut cells = Vec::new(); + let before = render(&mut view, &cells, /*width*/ 80, /*height*/ 24); + cells.push(Arc::new(crate::history_cell::StartupWarningsCell::new(vec![ + "Skill manifest is invalid.".into(), + ])) as Arc); + assert_eq!( + render(&mut view, &cells, /*width*/ 80, /*height*/ 24), + before + ); + view.sync_live_tail(/*width*/ 80, /*key*/ None, |_| None); + cells.insert(/*index*/ 0, header); + assert_eq!( + render(&mut view, &cells, /*width*/ 80, /*height*/ 24), + before + ); + assert_eq!(crate::history_cell::warning_count(&cells), 1); +} + +#[test] +fn hidden_initial_entries_do_not_add_a_separator_but_visible_entries_do() { + let cells = vec![cell(""), cell(""), cell("First"), cell(""), cell("Second")]; + let mut view = TranscriptView::default(); + view.sync_live_tail( + /*width*/ 20, + /*key*/ None, + |_| Some(vec![HyperlinkLine::from("Live")]), + ); + let frame = render(&mut view, &cells, /*width*/ 20, /*height*/ 5); + insta::assert_snapshot!(text(&frame), @" + First + + Second + + Live + "); + // Scrolling past the first visible entry must stop at its content, not its separator. + view.scroll(&cells, /*rows*/ -100); + assert_eq!(render(&mut view, &cells, /*width*/ 20, /*height*/ 5), frame); +} + #[test] fn reading_survives_prepend_and_new_output_then_returns_to_latest() { let mut cells = vec![cell("older\nline two"), cell("current\nlast line")]; diff --git a/codex-rs/tui/src/tui_startup_tests.rs b/codex-rs/tui/src/tui_startup_tests.rs index a335ca9785..b0e27aa648 100644 --- a/codex-rs/tui/src/tui_startup_tests.rs +++ b/codex-rs/tui/src/tui_startup_tests.rs @@ -211,6 +211,7 @@ async fn startup_typeahead_pty_child() { use_alt_screen: false, transcript_mode: crate::transcript_mode::TranscriptMode::Terminal, status_line_enabled: true, + welcome_motion: crate::motion::MotionMode::Animated, keymap: crate::keymap::RuntimeKeymap::defaults(), disable_paste_burst: false, },