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, },