diff --git a/codex-rs/tui/src/app/agents_overview_new.rs b/codex-rs/tui/src/app/agents_overview_new.rs index 1e64db5296..a07eafd7e3 100644 --- a/codex-rs/tui/src/app/agents_overview_new.rs +++ b/codex-rs/tui/src/app/agents_overview_new.rs @@ -56,7 +56,7 @@ impl App { Box::new(StartupDraftPump::new( tui, StartupDraftInitialScreen::Composer, - StartupDraftSessionAction::NewFromCommandCenter, + StartupDraftSessionAction::NewWhileRunning, )) }); let mut display_config = self.chat_widget.config_ref().clone(); diff --git a/codex-rs/tui/src/app/backend_selection.rs b/codex-rs/tui/src/app/backend_selection.rs index a83a621843..11325b0e83 100644 --- a/codex-rs/tui/src/app/backend_selection.rs +++ b/codex-rs/tui/src/app/backend_selection.rs @@ -9,6 +9,7 @@ use super::resume_preparation::ResumeSource; use super::session_lifecycle::PreparedFreshSession; use super::session_lifecycle::ThreadAttachPresentation; use super::*; +use crate::startup_draft::StartupDraftPump; use futures::TryFutureExt; pub(super) enum NewBackendSession { @@ -125,6 +126,33 @@ impl App { backends: &mut BackendSessions, owner: crate::backend_id::BackendId, session: NewBackendSession, + ) -> Result<(), String> { + let previous_thread = self.current_displayed_thread_id(); + let mut fresh_draft = None; + let result = Box::pin(self.switch_to_new_backend_session( + tui, + backends, + owner, + session, + &mut fresh_draft, + )) + .await; + if let Some(draft) = fresh_draft { + let opened_new_session = self.current_displayed_thread_id() != previous_thread; + draft + .handoff_after_navigation(tui, &mut self.chat_widget, opened_new_session) + .await; + } + result + } + + async fn switch_to_new_backend_session( + &mut self, + tui: &mut tui::Tui, + backends: &mut BackendSessions, + owner: crate::backend_id::BackendId, + session: NewBackendSession, + fresh_draft: &mut Option, ) -> Result<(), String> { let slot = backends .listening_slot(owner) @@ -166,7 +194,14 @@ impl App { let responsive_cloud_switch = settings.target == AppServerTarget::Cloud && matches!(session, NewBackendSession::CarryDraft); let (name, mut draft) = match session { - NewBackendSession::Fresh { name } => (name, None), + NewBackendSession::Fresh { name } => { + *fresh_draft = Some(StartupDraftPump::for_new_session( + tui, + &settings.local_settings, + settings.config.cwd.as_path(), + )); + (name, None) + } NewBackendSession::CarryDraft => ( self.chat_widget.thread_name(), (!responsive_cloud_switch).then(|| self.chat_widget.composer_draft_snapshot()), @@ -186,7 +221,7 @@ impl App { let events = tui.event_stream(); wait_for_cloud_switch(self, tui, bootstrap, events, clipboard_after_bootstrap).await } else { - bootstrap.await + StartupDraftPump::run_during_navigation(fresh_draft.as_mut(), tui, bootstrap).await }?; let destination_cwd = settings.config.cwd.to_path_buf(); if let Err(control) = self @@ -202,7 +237,7 @@ impl App { .map(|_| crate::onboarding::TrustCancelAction::CurrentTask), ..Default::default() }, - /*startup_draft*/ None, + fresh_draft.as_mut(), ) .await { @@ -215,15 +250,32 @@ impl App { } return Ok(()); } - let (config, local_settings) = self - .load_new_session_config_from( + let loaded = if fresh_draft.is_some() { + StartupDraftPump::run_during_navigation( + fresh_draft.as_mut(), + tui, + Box::pin(self.load_new_session_config_from( + destination, + &settings, + destination_cwd, + /*cloud_switch*/ None, + )), + ) + .await + } else { + self.load_new_session_config_from( destination, &settings, destination_cwd, responsive_cloud_switch.then_some(&mut *tui), ) .await - .map_err(|error| format!("Failed to read new session defaults: {error}"))?; + }; + let (config, local_settings) = + loaded.map_err(|error| format!("Failed to read new session defaults: {error}"))?; + if let Some(draft) = fresh_draft.as_mut() { + draft.apply_settings(&local_settings, config.cwd.as_path()); + } let host_project_cwd = self.backend_preference_context.local_cwd.clone(); let start = destination .start_thread_with_session_start_source( @@ -246,7 +298,7 @@ impl App { ) .await } else { - start.await + StartupDraftPump::run_during_navigation(fresh_draft.as_mut(), tui, start).await }?; if responsive_cloud_switch { draft = Some(self.chat_widget.composer_draft_snapshot()); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ca89196d43..1da04329b3 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -6,6 +6,7 @@ use super::rate_limit_refresh::RateLimitReadStatus; use super::rate_limit_refresh::RateLimitRefreshOutcome; use super::resize_reflow::trailing_run_start; +use super::session_lifecycle::FreshSessionStart; use super::session_lifecycle::ThreadAttachPresentation; use super::*; use crate::app_event::CapturedSideStream; @@ -264,7 +265,7 @@ impl App { | AppEvent::NewSession { name } | AppEvent::NewSessionInCurrentWorktree { name } => { self.start_fresh_session( - tui, app_server, /*session_start_source*/ None, + tui, app_server, FreshSessionStart::NewConversation, /*initial_user_message*/ None, name, ) .await; @@ -293,7 +294,7 @@ impl App { self.start_fresh_session( tui, app_server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ None, ) @@ -557,7 +558,7 @@ impl App { self.start_fresh_session( tui, app_server, - Some(ThreadStartSource::Clear), + FreshSessionStart::PreserveCurrentView(Some(ThreadStartSource::Clear)), /*initial_user_message*/ None, name, ) diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index bf0c5b991f..df45b4ca65 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -19,6 +19,7 @@ use crate::app_server_session::is_server_shutting_down_error; use crate::app_server_session::source_agent_path; use crate::app_server_session::thread_blocks_direct_input; use crate::chatwidget::ThreadInputStateRestoreMode; +use crate::startup_draft::StartupDraftPump; use crate::streaming::commit_tick::CommitTickScope; use codex_app_server_protocol::ThreadPrepareApprovedPlanParams; use codex_model_provider_info::OPENAI_PROVIDER_ID; @@ -37,6 +38,11 @@ pub(super) enum ThreadAttachPresentation { SessionLineageAfterClear, } +pub(super) enum FreshSessionStart { + NewConversation, + PreserveCurrentView(Option), +} + pub(super) struct PreparedFreshSession { pub(super) started: AppServerStartedThread, pub(super) config: Config, @@ -1217,7 +1223,7 @@ impl App { &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, - session_start_source: Option, + start: FreshSessionStart, initial_user_message: Option, new_thread_name: Option, ) { @@ -1251,16 +1257,36 @@ impl App { // Start a fresh in-memory session while preserving resumability via persisted rollout // history. If an initial message is provided, `enqueue_primary_thread_session` suppresses it // until the new session is configured and any replayed turns have been rendered. - match self - .prepare_fresh_session(app_server, session_start_source) - .await - { + let (session_start_source, mut draft) = match start { + FreshSessionStart::NewConversation => ( + None, + Some(StartupDraftPump::for_new_session( + tui, + &self.local_settings, + self.chat_widget.config_ref().cwd.as_path(), + )), + ), + FreshSessionStart::PreserveCurrentView(source) => (source, None), + }; + let previous_thread = self.current_displayed_thread_id(); + let prepared = StartupDraftPump::run_during_navigation( + draft.as_mut(), + tui, + Box::pin(self.prepare_fresh_session(app_server, session_start_source)), + ) + .await; + match prepared { Ok(prepared) => { + let presentation = if draft.is_some() { + ThreadAttachPresentation::FreshWithDraft + } else { + ThreadAttachPresentation::Fresh + }; self.adopt_fresh_session( tui, app_server, prepared, - ThreadAttachPresentation::Fresh, + presentation, initial_user_message, new_thread_name, ) @@ -1273,7 +1299,14 @@ impl App { } } } - tui.frame_requester().schedule_frame(); + if let Some(draft) = draft { + let opened_new_session = self.current_displayed_thread_id() != previous_thread; + draft + .handoff_after_navigation(tui, &mut self.chat_widget, opened_new_session) + .await; + } else { + tui.frame_requester().schedule_frame(); + } } async fn prepare_fresh_session( 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 5b8e38ea06..0337fd0fe8 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 @@ -2,6 +2,7 @@ use super::*; use crate::app::agents_overview::AGENTS_OVERVIEW_VIEW_ID; +use crate::app::session_lifecycle::FreshSessionStart; use crate::app::tests::session_lifecycle_requests::HistoryCapabilities; use crate::app::tests::session_lifecycle_requests::recorded_params; use crate::app::tests::session_lifecycle_requests::start_recording_app_server_with_history; @@ -912,7 +913,7 @@ async fn command_center_new_restores_blank_drafts_and_builtin_permissions() -> R app.start_fresh_session( &mut tui, &mut server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ None, ) diff --git a/codex-rs/tui/src/app/tests/new_session_tests.rs b/codex-rs/tui/src/app/tests/new_session_tests.rs index 84494877ad..f32fda29ff 100644 --- a/codex-rs/tui/src/app/tests/new_session_tests.rs +++ b/codex-rs/tui/src/app/tests/new_session_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::app::backend_sessions::BackendSessions; +use crate::app::session_lifecycle::FreshSessionStart; use crate::app_server_session::AppServerSessionEvent; use codex_app_server_protocol::SkillMetadata; use codex_app_server_protocol::SkillScope; @@ -63,7 +64,7 @@ async fn new_session_refreshes_account_for_the_started_provider() -> Result<()> Box::pin(app.start_fresh_session( &mut tui, &mut server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ None, )) @@ -183,7 +184,7 @@ async fn new_sessions_preserve_yolo_launch_and_later_permission_choices() -> Res app.start_fresh_session( &mut tui, &mut server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ None, ) @@ -244,7 +245,7 @@ async fn new_session_preserves_vim_line_yank() -> Result<()> { app.start_fresh_session( &mut tui, &mut server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ None, ) @@ -459,7 +460,7 @@ async fn replacement_uses_server_defaults_and_preserves_explicit_launch_settings app.start_fresh_session( &mut tui, &mut server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ None, ) @@ -759,7 +760,7 @@ async fn replacement_failure_keeps_current_task_and_restores_input() -> Result<( app.start_fresh_session( &mut tui, &mut server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), Some("keep this request".into()), /*new_thread_name*/ None, ) @@ -903,6 +904,7 @@ async fn backend_handoff_errors_are_snapshotted() -> Result<()> { let trust_error = next_history_message(&mut events); insta::assert_snapshot!(trust_error, @"■ Unable to check folder trust: config/read failed while checking remote project trust"); + tui.pause_events(); // Starting a fresh thread must observe the same destination trust check as resuming one. app.start_new_session_on_other_backend( &mut tui, @@ -1373,7 +1375,7 @@ async fn replacement_preserves_remote_launch_paths_and_older_servers() -> Result app.start_fresh_session( &mut tui, &mut server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ None, ) diff --git a/codex-rs/tui/src/app/tests/projectless_tests.rs b/codex-rs/tui/src/app/tests/projectless_tests.rs index 3e61957074..8007b7ee5f 100644 --- a/codex-rs/tui/src/app/tests/projectless_tests.rs +++ b/codex-rs/tui/src/app/tests/projectless_tests.rs @@ -3,6 +3,7 @@ use super::session_lifecycle_requests::HistoryCapabilities; use super::session_lifecycle_requests::start_recording_app_server_with_history; use super::*; +use crate::app::session_lifecycle::FreshSessionStart; use codex_context_fragments::side_conversation_boundary; use pretty_assertions::assert_eq; @@ -52,7 +53,7 @@ sandbox = "unelevated" app.start_fresh_session( &mut tui, &mut server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ None, ) diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 98587be60a..a69ca9b77d 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::app::session_lifecycle::FreshSessionStart; use crate::app_event::TranscriptExportDestination; use crate::bottom_pane::BottomPaneView; use crate::test_support::boxed_future; @@ -4833,7 +4834,7 @@ model_reasoning_effort = "low" app.start_fresh_session( &mut tui, &mut server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ None, ) @@ -5197,7 +5198,7 @@ terminal_visualization_instructions = true app.start_fresh_session( &mut tui, &mut server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ None, ) @@ -5372,9 +5373,14 @@ async fn change_directory_scenario() -> Result<()> { start_recording_app_server(&app.config, no_list, background).await?; let (rec, plain, req) = (recorded_params, crate::key_hint::plain, &requests); let mut tui = crate::tui::test_support::make_test_tui()?; - let (source, message, name) = (None, None, Some("Previous project".to_string())); - app.start_fresh_session(&mut tui, &mut server, source, message, name) - .await; + app.start_fresh_session( + &mut tui, + &mut server, + FreshSessionStart::PreserveCurrentView(None), + /*initial_user_message*/ None, + Some("Previous project".to_string()), + ) + .await; let original = app.chat_widget.thread_id().expect("original thread"); let rollout = app.chat_widget.rollout_path().expect("original rollout"); let json = r#"{"type":"message","role":"assistant","content":[{"type":"output_text","text":"saved history"}]}"#; @@ -5781,7 +5787,7 @@ fn fresh_session_keeps_its_name_and_draft_when_cycling_backends() -> Result<()> app.start_fresh_session( &mut tui, &mut app_server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ Some("Add User".to_string()), ) @@ -5919,6 +5925,7 @@ fn fresh_session_keeps_its_name_and_draft_when_cycling_backends() -> Result<()> .contains_key(&destination_thread) ); assert!(!app.thread_event_channels.contains_key(&destination_thread)); + tui.pause_events(); // An automatic fresh session may use another connection without changing // the user's last explicit choice. app.start_new_session_on_other_backend( @@ -6087,7 +6094,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { app.start_fresh_session( &mut tui, &mut app_server, - /*session_start_source*/ None, + FreshSessionStart::PreserveCurrentView(None), /*initial_user_message*/ None, /*new_thread_name*/ None, ) diff --git a/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs b/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs index b501425a16..da55fbd6ac 100644 --- a/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs +++ b/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs @@ -232,6 +232,7 @@ async fn unavailable_thread_new_and_clear_start_a_writable_session() -> Result<( .set_local_worktree_operations(/*enabled*/ false); let mut session = start_config_write_test_app_server(&app).await?; let mut tui = crate::tui::test_support::make_test_tui()?; + tui.pause_events(); while events.try_recv().is_ok() {} app.chat_widget .restore_user_message_to_composer(command.into()); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs index df7cec2916..166ee6f001 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs @@ -109,6 +109,14 @@ impl ChatComposer { self.draft.paste_burst.handoff_deadline() } + pub(in crate::bottom_pane) fn has_paste_window(&self, now: Instant) -> bool { + !self.draft.disable_paste_burst + && self + .draft + .paste_burst + .newline_should_insert_instead_of_submit(now) + } + /// Display flushed text without losing the window for its trailing paste keys. pub(in crate::bottom_pane) fn flush_handoff_paste(&mut self, now: Instant) -> bool { let deadline = self.paste_handoff_deadline(); diff --git a/codex-rs/tui/src/bottom_pane/startup.rs b/codex-rs/tui/src/bottom_pane/startup.rs index 60455841c4..7ce2be0850 100644 --- a/codex-rs/tui/src/bottom_pane/startup.rs +++ b/codex-rs/tui/src/bottom_pane/startup.rs @@ -131,6 +131,10 @@ impl BottomPane { self.composer.paste_handoff_deadline() } + pub(crate) fn has_startup_paste_window(&self, now: Instant) -> bool { + self.composer.has_paste_window(now) + } + pub(crate) fn flush_handoff_paste_if_due(&mut self) -> bool { self.composer .flush_handoff_paste(tokio::time::Instant::now().into_std()) diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index 95ad288d80..e1f6338030 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -76,7 +76,8 @@ pub(crate) enum StartupDraftInitialScreen { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum StartupDraftSessionAction { New, - NewFromCommandCenter, + /// Opened from a live TUI. Submission stays with the eventual conversation. + NewWhileRunning, Resume, Fork, } @@ -201,7 +202,7 @@ impl StartupDraftPump { let mut blossom = crate::empty_state_animation::EmptyStateAnimation::default(); if matches!( session_action, - StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter + StartupDraftSessionAction::New | StartupDraftSessionAction::NewWhileRunning ) { blossom.start_fresh(); } @@ -228,6 +229,68 @@ impl StartupDraftPump { } } + pub(crate) fn for_new_session(tui: &Tui, settings: &LocalSettings, cwd: &Path) -> Self { + let mut draft = Self::new( + tui, + StartupDraftInitialScreen::Composer, + StartupDraftSessionAction::NewWhileRunning, + ); + draft.apply_settings(settings, cwd); + draft + } + + /// A thread start can commit before its response arrives. Keep waiting for the result + /// if the terminal stops accepting input or drawing the provisional composer. + pub(crate) async fn run_during_navigation( + draft: Option<&mut Self>, + tui: &mut Tui, + operation: F, + ) -> F::Output + where + F: Future, + { + if let Some(draft) = draft { + tokio::pin!(operation); + match draft.run_until(tui, operation.as_mut()).await { + Ok(result) => result, + Err(error) => { + tracing::warn!(%error, "failed to update the new conversation composer"); + operation.await + } + } + } else { + operation.await + } + } + + pub(crate) async fn handoff_after_navigation( + mut self, + tui: &mut Tui, + chat_widget: &mut crate::chatwidget::ChatWidget, + opened_new_session: bool, + ) { + if !opened_new_session { + self.bottom_pane.set_footer_hint_override(Some(vec![( + "New conversation did not open".into(), + "draft kept".into(), + )])); + if let Err(error) = self.show(tui) { + tracing::warn!(%error, "failed to render the new conversation failure"); + } + } + if let Err(error) = self.settle_navigation_input(tui).await { + tracing::warn!(%error, "failed to finish input for the new conversation composer"); + } + if opened_new_session { + chat_widget + .empty_state_animation + .borrow_mut() + .continue_from(&mut self.blossom.borrow_mut()); + } + chat_widget.restore_startup_draft(self.take_draft()); + tui.frame_requester().schedule_frame(); + } + pub(crate) fn take_draft(&mut self) -> ComposerDraftSnapshot { self.bottom_pane.flush_composer_paste_burst(); self.bottom_pane.composer_draft_snapshot() @@ -379,6 +442,31 @@ impl StartupDraftPump { } } + /// Keep raw paste keys in the temporary composer until its paste window closes. + async fn settle_navigation_input(&mut self, tui: &mut Tui) -> io::Result<()> { + loop { + for _ in 0..STARTUP_EVENT_BATCH_SIZE { + let deadline = self + .bottom_pane + .composer_paste_deadline() + .map(Instant::from_std) + .max( + self.pending_paste_newline + .as_ref() + .map(|(started_at, _)| *started_at + STARTUP_PASTE_NEWLINE_TIMEOUT), + ) + .unwrap_or_else(Instant::now); + let Ok(Some(event)) = tokio::time::timeout_at(deadline, self.events.next()).await + else { + return Ok(()); + }; + self.handle_event(tui, event)?; + } + + tokio::task::yield_now().await; + } + } + /// Resolve an ambiguous paste newline before its provisional input owner disappears. pub(crate) async fn flush_pending_paste_newline(&mut self, tui: &mut Tui) -> io::Result<()> { while let Some((started_at, _)) = &self.pending_paste_newline { @@ -430,7 +518,12 @@ impl StartupDraftPump { } fn draw(&mut self, tui: &mut Tui, screen_size: Size) -> io::Result<()> { - if self.bottom_pane.flush_paste_burst_if_due() { + let flushed = if self.session_action == StartupDraftSessionAction::NewWhileRunning { + self.bottom_pane.flush_handoff_paste_if_due() + } else { + self.bottom_pane.flush_paste_burst_if_due() + }; + if flushed { crate::startup_recovery::remember(self.bottom_pane.composer_recovery_snapshot()); } if self.bottom_pane.is_in_paste_burst() { @@ -458,7 +551,7 @@ impl StartupDraftPump { let Some(key) = crate::keymap::dispatch_event(action) else { return false; }; - self.session_action != StartupDraftSessionAction::NewFromCommandCenter + self.session_action != StartupDraftSessionAction::NewWhileRunning && self.bottom_pane.is_startup_submit_key(key) || !self.bottom_pane.is_startup_composer_action(key) && self.bottom_pane.is_safe_startup_editor_key(key) @@ -497,7 +590,7 @@ fn startup_draft_renderable<'a>( let mut renderable = FlexRenderable::new(); renderable.push(/*flex*/ 1, RenderableItem::Borrowed(header)); let loading_message = match session_action { - StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => None, + StartupDraftSessionAction::New | StartupDraftSessionAction::NewWhileRunning => None, StartupDraftSessionAction::Resume => Some(" Resuming session…"), StartupDraftSessionAction::Fork => Some(" Forking session…"), }; diff --git a/codex-rs/tui/src/startup_draft_input.rs b/codex-rs/tui/src/startup_draft_input.rs index 13fab32955..935926af93 100644 --- a/codex-rs/tui/src/startup_draft_input.rs +++ b/codex-rs/tui/src/startup_draft_input.rs @@ -94,25 +94,31 @@ impl StartupDraftPump { }; if key.code == KeyCode::Enter && key.modifiers.is_empty() - && self.bottom_pane.is_in_paste_burst() + && (self.bottom_pane.is_startup_composer_action(key) + || !self.bottom_pane.is_safe_startup_editor_key(key)) { let text_len = self.bottom_pane.composer_text().len(); + let retained_paste = self.session_action + == super::StartupDraftSessionAction::NewWhileRunning + && !self.bottom_pane.is_in_paste_burst() + && self + .bottom_pane + .has_startup_paste_window(Instant::now().into_std()); self.bottom_pane.flush_composer_paste_burst(); - if self - .bottom_pane - .composer_text() - .len() - .saturating_sub(text_len) - > 1 - && (self.bottom_pane.is_startup_composer_action(key) - || !self.bottom_pane.is_safe_startup_editor_key(key)) + if retained_paste + || self + .bottom_pane + .composer_text() + .len() + .saturating_sub(text_len) + > 1 { self.pending_paste_newline = Some((Instant::now(), "\n".to_string())); } } if self.initial_screen == StartupDraftInitialScreen::Composer && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) - && self.session_action != super::StartupDraftSessionAction::NewFromCommandCenter + && self.session_action != super::StartupDraftSessionAction::NewWhileRunning && self.bottom_pane.is_startup_submit_key(key) { self.bottom_pane.flush_composer_paste_burst(); @@ -141,8 +147,7 @@ impl StartupDraftPump { } let previous_text = self.bottom_pane.composer_text(); if let Err(error) = handle_startup_draft_key(&mut self.bottom_pane, key) - && !(self.session_action - == super::StartupDraftSessionAction::NewFromCommandCenter + && !(self.session_action == super::StartupDraftSessionAction::NewWhileRunning && StartupCancelled::matches(&error)) { if StartupCancelled::matches(&error) @@ -179,7 +184,7 @@ impl StartupDraftPump { || !matches!( self.session_action, super::StartupDraftSessionAction::New - | super::StartupDraftSessionAction::NewFromCommandCenter + | super::StartupDraftSessionAction::NewWhileRunning ) { return Ok(()); diff --git a/codex-rs/tui/src/startup_draft_layout.rs b/codex-rs/tui/src/startup_draft_layout.rs index 5ff03aa8ed..f4594e4bf2 100644 --- a/codex-rs/tui/src/startup_draft_layout.rs +++ b/codex-rs/tui/src/startup_draft_layout.rs @@ -63,7 +63,7 @@ impl Renderable for OwnedStartupLayout<'_> { }; paragraph.render(header, buf); let message = match self.pump.session_action { - StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => { + StartupDraftSessionAction::New | StartupDraftSessionAction::NewWhileRunning => { let composer = (!self.pump.submission_pending) .then(|| self.pump.bottom_pane.empty_state_composer()) .flatten(); diff --git a/codex-rs/tui/src/startup_draft_layout_tests.rs b/codex-rs/tui/src/startup_draft_layout_tests.rs index c9e1dab58e..fa8a2e1295 100644 --- a/codex-rs/tui/src/startup_draft_layout_tests.rs +++ b/codex-rs/tui/src/startup_draft_layout_tests.rs @@ -47,7 +47,7 @@ fn new_startup_decoration_tracks_draft_and_session_action() { ("", StartupDraftSessionAction::New, true), ("", StartupDraftSessionAction::Resume, false), ("", StartupDraftSessionAction::Fork, false), - ("", StartupDraftSessionAction::NewFromCommandCenter, false), + ("", StartupDraftSessionAction::NewWhileRunning, false), ] { pump.session_action = action; pump.submission_pending = pending; diff --git a/codex-rs/tui/src/startup_draft_submission_tests.rs b/codex-rs/tui/src/startup_draft_submission_tests.rs index 6cc1777f78..5c232a5f91 100644 --- a/codex-rs/tui/src/startup_draft_submission_tests.rs +++ b/codex-rs/tui/src/startup_draft_submission_tests.rs @@ -32,7 +32,7 @@ async fn startup_enter_confirms_one_visible_draft_and_escape_cancels() { "keep this startup draft with more text" ); let draft = pump.bottom_pane.composer_draft_snapshot(); - pump.session_action = StartupDraftSessionAction::NewFromCommandCenter; + pump.session_action = StartupDraftSessionAction::NewWhileRunning; pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter))) .expect("command-center startup stays edit-only"); assert!(!pump.take_submission_intent()); diff --git a/codex-rs/tui/src/startup_draft_tests.rs b/codex-rs/tui/src/startup_draft_tests.rs index e3c6e77f05..64d2928b17 100644 --- a/codex-rs/tui/src/startup_draft_tests.rs +++ b/codex-rs/tui/src/startup_draft_tests.rs @@ -65,6 +65,123 @@ pub(crate) fn startup_test_pump_with_input(text: &str) -> StartupDraftPump { pump } +#[tokio::test] +async fn new_session_composer_renders_edits_before_thread_start_finishes() { + let (finish_start, started) = tokio::sync::oneshot::channel(); + let mut pump = startup_test_pump( + [ + TuiEvent::Paste("draft while opening".into()), + TuiEvent::Key(KeyCode::Enter.into()), + ] + .into_iter(), + ); + pump.session_action = StartupDraftSessionAction::NewWhileRunning; + pump.events = Box::pin( + pump.events + .chain(futures::stream::once(async move { + finish_start.send(()).expect("release thread start"); + TuiEvent::Draw + })) + .chain(futures::stream::pending()), + ); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + + StartupDraftPump::run_during_navigation(Some(&mut pump), &mut tui, started) + .await + .expect("finish thread start"); + + let frame = tui.terminal.previous_buffer(); + let draft_line = frame + .content() + .chunks(usize::from(frame.area.width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .find(|row| row.contains("draft while opening")) + .expect("the typed draft was visible while thread start was pending"); + insta::assert_snapshot!(draft_line.trim(), @"› draft while opening"); + assert_eq!(pump.take_draft().text, "draft while opening"); + assert!(!pump.take_submission_intent()); +} + +#[tokio::test] +async fn failed_new_session_drains_queued_enter_before_returning_the_draft() { + let mut pump = quiet_startup_test_pump(); + pump.session_action = StartupDraftSessionAction::NewWhileRunning; + pump.bottom_pane.insert_str("draft for the new thread"); + let (enter_polled, mut enter_was_polled) = tokio::sync::oneshot::channel(); + pump.events = Box::pin(futures::stream::once(async move { + enter_polled.send(()).expect("observe the queued Enter"); + TuiEvent::Key(KeyCode::Enter.into()) + })); + let (mut old_chat, _events, _rx, _ops) = + crate::chatwidget::tests::make_chatwidget_manual_with_sender().await; + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + + pump.handoff_after_navigation(&mut tui, &mut old_chat, /*opened_new_session*/ false) + .await; + + assert_eq!(enter_was_polled.try_recv(), Ok(())); + let frame = tui.terminal.previous_buffer(); + let failure_hint = frame + .content() + .chunks(usize::from(frame.area.width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .find(|row| row.contains("New conversation did not open")) + .expect("the failure frame was painted before the handoff returned"); + insta::assert_snapshot!(failure_hint.trim(), @"New conversation did not open draft kept"); + assert_eq!( + old_chat.composer_text_with_pending(), + "draft for the new thread" + ); +} + +#[tokio::test(start_paused = true)] +async fn new_session_handoff_keeps_delayed_raw_paste_newline() { + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + for draw_before_newline in [false, true] { + let mut pump = quiet_startup_test_pump(); + pump.session_action = StartupDraftSessionAction::NewWhileRunning; + for character in "first line".chars() { + pump.handle_event(&mut tui, TuiEvent::Key(KeyCode::Char(character).into())) + .expect("buffer the first paste line"); + } + pump.events = Box::pin( + futures::stream::once(async move { + tokio::time::sleep(std::time::Duration::from_millis(/*millis*/ 70)).await; + if draw_before_newline { + TuiEvent::Draw + } else { + TuiEvent::Key(KeyCode::Enter.into()) + } + }) + .chain(futures::stream::iter( + draw_before_newline + .then_some(TuiEvent::Key(KeyCode::Enter.into())) + .into_iter() + .chain( + "second line" + .chars() + .map(|c| TuiEvent::Key(KeyCode::Char(c).into())), + ), + )), + ); + let (mut chat, _events, _rx, _ops) = + crate::chatwidget::tests::make_chatwidget_manual_with_sender().await; + + pump.handoff_after_navigation(&mut tui, &mut chat, /*opened_new_session*/ true) + .await; + + assert_eq!(chat.composer_text_with_pending(), "first line\nsecond line"); + } +} + #[test] fn startup_draft_renders_full_empty_and_multiline_composer_frames() { let mut snapshots = Vec::new();