diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index e8603718c9..df3453ad53 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -2,6 +2,7 @@ //! //! Owns the main app run loop from app-server bootstrap through terminal shutdown. Startup input //! remains isolated from protected interactive requests until the initialized composer owns it. +//! Queued resume history replaces the provisional loading frame only when it is ready to render. use super::reconnect::ReconnectState; use super::*; @@ -100,6 +101,26 @@ pub(super) fn startup_model( } impl App { + /// Keep the provisional loading frame until queued history reaches the owned transcript. + /// Visible startup decisions and the agent overview must still render immediately. + pub(super) fn render_startup_frame( + &mut self, + tui: &mut tui::Tui, + app_event_rx: &mpsc::UnboundedReceiver, + ) -> Result<()> { + if tui.is_owned_screen() && !app_event_rx.is_empty() && !self.chat_widget.has_active_view() + { + return Ok(()); + } + self.chat_widget.pre_draw_tick(); + self.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?; + if self.chat_widget.has_active_view() && self.startup_protected_input_boundary { + tui.discard_pending_input_before_interactive_screen()?; + self.startup_pending_protected_request = false; + } + Ok(()) + } + /// Recognizes queued requests before they become visible protected screens. pub(super) fn has_queued_startup_protected_request(&self) -> bool { self.startup_protected_input_boundary @@ -172,16 +193,6 @@ impl App { Err(error.into()) } - fn render_startup_frame(app: &mut App, tui: &mut tui::Tui) -> Result<()> { - app.chat_widget.pre_draw_tick(); - app.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?; - if app.chat_widget.has_active_view() && app.startup_protected_input_boundary { - tui.discard_pending_input_before_interactive_screen()?; - app.startup_pending_protected_request = false; - } - Ok(()) - } - // Adopt actual launch ownership before constructing session-local preferences. tui.prepare_owned_screen(config.features.enabled(Feature::TranscriptV2))?; let mut local_settings = crate::local_settings::LocalSettings::for_tui(&config, tui); @@ -922,12 +933,15 @@ See the Codex keymap documentation for supported actions and examples." { return shutdown_on_startup_error(app_server, err).await; } - // Input for the cancelled resume/fork must not appear in a later selected session. - let mut pending_startup_draft = - (!startup_session_cancelled).then(|| startup_draft.into_draft()); + // Keep cancelled resume/fork text editable, but never carry confirmation to another session. + let mut pending_startup_submission = + startup_draft.take_submission_intent() && !startup_session_cancelled; + let mut pending_startup_draft = Some(startup_draft.into_draft()); if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() { - app.chat_widget - .restore_startup_draft_when_ready(&mut pending_startup_draft); + app.chat_widget.restore_startup_input_when_ready( + &mut pending_startup_draft, + &mut pending_startup_submission, + ); } #[cfg(windows)] @@ -940,7 +954,7 @@ See the Codex keymap documentation for supported actions and examples." let event_stream_started_at = Instant::now(); tui.schedule_screen_size_recheck(Duration::ZERO); - if let Err(err) = render_startup_frame(&mut app, tui) { + if let Err(err) = app.render_startup_frame(tui, &app_event_rx) { return shutdown_on_startup_error(app_server, err).await; } let tui_events = tui.event_stream(); @@ -1094,7 +1108,7 @@ See the Codex keymap documentation for supported actions and examples." } if !had_active_view && app.chat_widget.has_active_view() - && let Err(err) = render_startup_frame(&mut app, tui) + && let Err(err) = app.render_startup_frame(tui, &app_event_rx) { break Err(err); } @@ -1236,16 +1250,22 @@ See the Codex keymap documentation for supported actions and examples." } if !had_active_view && app.chat_widget.has_active_view() - && let Err(err) = render_startup_frame(&mut app, tui) + && let Err(err) = app.render_startup_frame(tui, &app_event_rx) { break Err(err); } } match control { AppRunControl::Continue => { + if app.reconnect.offline { + pending_startup_submission = false; + app.chat_widget.cancel_startup_submission(); + } if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() { - app.chat_widget - .restore_startup_draft_when_ready(&mut pending_startup_draft); + app.chat_widget.restore_startup_input_when_ready( + &mut pending_startup_draft, + &mut pending_startup_submission, + ); } #[cfg(windows)] if terminal_color_probe_pending @@ -1264,6 +1284,7 @@ See the Codex keymap documentation for supported actions and examples." } let clear_pet_result = tui.clear_ambient_pet_image(); let clear_result = tui.terminal.clear(); + // Keep the alternate screen active until the outer guard restores both keyboard stacks. let exit_reason = match exit_reason_result { Ok(exit_reason) => { clear_pet_result?; diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index f2570d119d..a9d5c7240b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -76,6 +76,8 @@ mod safety_buffering; mod session_lifecycle_requests; mod session_summary; mod startup; +#[path = "tests/startup_frame_tests.rs"] +mod startup_frame_tests; #[path = "tests/startup_warnings_tests.rs"] mod startup_warnings_tests; #[path = "tests/stream_animation_tests.rs"] diff --git a/codex-rs/tui/src/app/tests/startup_frame_tests.rs b/codex-rs/tui/src/app/tests/startup_frame_tests.rs new file mode 100644 index 0000000000..a2028e6495 --- /dev/null +++ b/codex-rs/tui/src/app/tests/startup_frame_tests.rs @@ -0,0 +1,131 @@ +//! Startup handoff preserves the loading frame until replayed cells can replace it. + +use super::*; +use crate::custom_terminal::test_support::last_rendered_buffer; +use crate::startup_draft::tests::quiet_startup_test_pump; +use pretty_assertions::assert_eq; + +fn frame_text(tui: &tui::Tui) -> String { + last_rendered_buffer(&tui.terminal) + .content + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect() +} + +#[tokio::test] +async fn owned_startup_preserves_loading_until_resume_replay_is_applied() -> Result<()> { + for items in [ + Vec::new(), + vec![ThreadItem::AgentMessage { + id: "answer".into(), + text: "Retained **answer** after resume.".into(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }], + ] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.local_settings.tui.show_tooltips = false; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let thread_id = ThreadId::new(); + let mut startup = quiet_startup_test_pump(); + startup.apply_config(&app.config); + startup.update_session_selection( + &mut tui, + &SessionSelection::Resume(crate::resume_picker::SessionTarget { + path: None, + thread_id, + cwd: None, + history_mode: None, + }), + )?; + let loading = last_rendered_buffer(&tui.terminal).clone(); + let loading_cursor = tui.terminal.last_known_cursor_pos; + assert!(frame_text(&tui).contains("Resuming session…")); + + let has_answer = !items.is_empty(); + let turns = if has_answer { + vec![test_turn("resumed-turn", TurnStatus::Completed, items)] + } else { + Vec::new() + }; + app.enqueue_primary_thread_session( + test_thread_session(thread_id, app.config.cwd.to_path_buf()), + turns, + ) + .await?; + let mut draft = Some(startup.into_draft()); + assert!(!events.is_empty()); + assert!(app.transcript_cells.is_empty()); + while !events.is_empty() { + app.render_startup_frame(&mut tui, &events)?; + assert_eq!(last_rendered_buffer(&tui.terminal), &loading); + assert_eq!(tui.terminal.last_known_cursor_pos, loading_cursor); + let event = events.try_recv()?; + let control = Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; + assert!(matches!(control, AppRunControl::Continue)); + } + app.chat_widget.restore_startup_draft_when_ready(&mut draft); + app.render_startup_frame(&mut tui, &events)?; + assert!(draft.is_none()); + let rendered = frame_text(&tui); + assert!(!rendered.contains("Resuming session…")); + if has_answer { + assert!(rendered.contains("Retained answer after resume.")); + } else { + assert!(rendered.contains("OpenAI Codex")); + } + tui.set_owned_screen(/*owned*/ false)?; + app_server.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn owned_startup_renders_visible_decisions_and_overview_before_pending_events() -> Result<()> +{ + for overview in [false, true] { + let (mut app, events, _ops) = make_test_app_with_channels().await; + app.app_event_tx + .send(AppEvent::BeginInitialHistoryReplayBuffer); + if overview { + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + } else { + let preset = codex_utils_approval_presets::builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + app.chat_widget + .open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); + } + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + assert!(!events.is_empty()); + app.render_startup_frame(&mut tui, &events)?; + assert!(frame_text(&tui).contains(if overview { + "Agent command center" + } else { + "sandbox" + })); + tui.set_owned_screen(/*owned*/ false)?; + } + Ok(()) +} + +#[tokio::test] +async fn inline_startup_still_renders_with_pending_history() -> Result<()> { + let (mut app, events, _ops) = make_test_app_with_channels().await; + app.app_event_tx + .send(AppEvent::BeginInitialHistoryReplayBuffer); + app.chat_widget + .apply_external_edit("inline startup draft".into()); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.render_startup_frame(&mut tui, &events)?; + assert!(frame_text(&tui).contains("inline startup draft")); + Ok(()) +} diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 9d719edbd6..640ea5b992 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -762,7 +762,14 @@ impl App { ) .await { - Ok(_) => return Ok(true), + Ok(_) => { + if self.active_thread_id == Some(thread_id) + && self.chat_widget.thread_id() == Some(thread_id) + { + crate::startup_recovery::acknowledged(client_user_message_id); + } + return Ok(true); + } Err(error) => { if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) @@ -882,6 +889,7 @@ impl App { if self.active_thread_id == Some(thread_id) && self.chat_widget.thread_id() == Some(thread_id) { + crate::startup_recovery::acknowledged(client_user_message_id); self.chat_widget .record_safety_buffering_turn(response.turn.id, op); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 31bf96e783..72c251bed2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -89,8 +89,10 @@ //! //! # Startup Draft Handoff //! -//! Startup uses a provisional plain-text composer: editing remains available, but submission, -//! popups, attachments, and other actions are disabled. [`ComposerDraftSnapshot`] transfers its +//! Startup uses a provisional plain-text composer: editing remains available while its owner +//! records one submit intent. Popups, attachments, and direct dispatch stay disabled. After the +//! protected startup gates finish, `prepare_startup_submission` applies normal submission parsing +//! once, including expansion of paste placeholders. [`ComposerDraftSnapshot`] transfers its //! text, cursor, pending paste placeholders, local history, and recent activity to the fully //! initialized composer. //! `ChatWidget` merges the draft with any existing initial prompt and attachments, rebasing cursor @@ -3123,6 +3125,15 @@ impl ChatComposer { result } + /// Prepare a startup-confirmed draft through the normal queue parser, independent of keymaps. + pub(super) fn prepare_startup_submission(&mut self) -> InputResult { + // Startup's plain-text editor cannot show shell mode; require confirmation here. + if self.is_bang_shell_command() { + return InputResult::None; + } + self.handle_submission(/*should_queue*/ true).0 + } + fn reset_vim_mode_after_successful_dispatch(&mut self, result: &InputResult) { if matches!( result, 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 380949f5ae..1d12477ff0 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 @@ -4,6 +4,19 @@ use super::*; use crate::bottom_pane::paste_burst::FlushResult; impl ChatComposer { + /// Include accepted but unflushed keys without changing live paste detection. + pub(crate) fn recovery_snapshot(&self) -> ComposerDraftSnapshot { + let mut snapshot = self.draft_snapshot(); + if let Some(pending) = self.draft.paste_burst.clone().flush_before_modified_input() { + let mut textarea = TextArea::new(); + textarea.set_text_with_elements(&snapshot.text, &snapshot.text_elements); + textarea.insert_str_at(snapshot.cursor, &pending); + snapshot.text = textarea.text().to_owned(); + snapshot.text_elements = textarea.text_elements(); + } + snapshot + } + /// Classify an explicit paste before integrating text shared with the buffered key path. pub fn handle_paste(&mut self, pasted: String) -> bool { self.note_sparkle_paste(&pasted); diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index 5fcd6615f0..8084f6b9fe 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -169,7 +169,7 @@ const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8); #[cfg(windows)] const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60); -#[derive(Default)] +#[derive(Clone, Default)] pub(crate) struct PasteBurst { last_plain_char_time: Option, consecutive_plain_char_burst: u16, diff --git a/codex-rs/tui/src/bottom_pane/startup.rs b/codex-rs/tui/src/bottom_pane/startup.rs index 86b73cb729..403b16a730 100644 --- a/codex-rs/tui/src/bottom_pane/startup.rs +++ b/codex-rs/tui/src/bottom_pane/startup.rs @@ -1,7 +1,7 @@ //! Bottom-pane adapters for the provisional startup composer and its protected handoff. //! -//! Startup accepts safe editor input while keeping submission, search, and other actions -//! unavailable. Once initialization finishes, these adapters preserve buffered paste text, +//! Startup accepts safe editor input and records submission intent while keeping search and +//! other actions unavailable. Once initialization finishes, these adapters preserve buffered paste text, //! composer history and cursor state, and the approval-idle timing inherited by the real composer. use std::time::Instant; @@ -14,6 +14,16 @@ use super::HistoryEntry; use crate::key_hint::KeyBindingListExt; impl BottomPane { + /// Use the effective submit binding for startup confirmation as soon as config is loaded. + pub(crate) fn is_startup_submit_key(&self, key_event: KeyEvent) -> bool { + self.keymap.composer.submit.is_pressed(key_event) + } + + /// Consume a confirmed startup draft only after its caller has checked submission readiness. + pub(crate) fn prepare_startup_submission(&mut self) -> super::InputResult { + self.composer.prepare_startup_submission() + } + /// Return whether a key would submit, queue, or search outside the startup draft. pub(crate) fn is_startup_composer_action(&self, key_event: KeyEvent) -> bool { let composer = &self.keymap.composer; @@ -30,9 +40,27 @@ impl BottomPane { /// Return whether a key is bound only to local startup-safe text editing. pub(crate) fn is_safe_startup_editor_key(&self, key_event: KeyEvent) -> bool { + let editor = &self.keymap.editor; + self.is_startup_cursor_key(key_event) + || [ + &editor.insert_newline, + &editor.delete_backward, + &editor.delete_forward, + &editor.delete_backward_word, + &editor.delete_forward_word, + &editor.kill_line_start, + &editor.kill_whole_line, + &editor.kill_line_end, + &editor.yank, + ] + .into_iter() + .any(|bindings| bindings.is_pressed(key_event)) + } + + /// Resolve cursor movement through the same configured bindings as the editor. + pub(crate) fn is_startup_cursor_key(&self, key_event: KeyEvent) -> bool { let editor = &self.keymap.editor; [ - &editor.insert_newline, &editor.move_left, &editor.move_right, &editor.move_up, @@ -41,14 +69,6 @@ impl BottomPane { &editor.move_word_right, &editor.move_line_start, &editor.move_line_end, - &editor.delete_backward, - &editor.delete_forward, - &editor.delete_backward_word, - &editor.delete_forward_word, - &editor.kill_line_start, - &editor.kill_whole_line, - &editor.kill_line_end, - &editor.yank, ] .into_iter() .any(|bindings| bindings.is_pressed(key_event)) @@ -73,6 +93,11 @@ impl BottomPane { draft } + /// Preserve keys still buffered by paste detection if startup exits before its next frame. + pub(crate) fn composer_recovery_snapshot(&self) -> ComposerDraftSnapshot { + self.composer.recovery_snapshot() + } + /// Restore the visible cursor before synchronizing composer-owned popups. pub(crate) fn set_composer_cursor(&mut self, cursor: usize) { self.composer.set_current_cursor(cursor); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ec2fc025dd..43031bb1f2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -348,6 +348,7 @@ mod input_restore; mod input_submission; mod interrupts; mod questions; +mod startup_submission; use self::interrupts::InterruptManager; mod keymap_picker; mod mcp_startup; diff --git a/codex-rs/tui/src/chatwidget/input_queue.rs b/codex-rs/tui/src/chatwidget/input_queue.rs index e4de46d51f..4ef1fe6e12 100644 --- a/codex-rs/tui/src/chatwidget/input_queue.rs +++ b/codex-rs/tui/src/chatwidget/input_queue.rs @@ -21,6 +21,8 @@ pub(super) struct PendingInputPreview { #[derive(Debug, Default)] pub(super) struct InputQueueState { + /// The visible draft confirmed during startup, awaiting the protected-input handoff. + pub(super) startup_submission: Option, /// User inputs queued while a turn is in progress. pub(super) queued_user_messages: VecDeque, /// History records for queued user messages. Slash commands such as `/goal` @@ -56,6 +58,7 @@ impl InputQueueState { } pub(super) fn clear(&mut self) { + self.startup_submission = None; self.recovered_queue = false; self.queued_user_messages.clear(); self.queued_user_message_history_records.clear(); diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index 6377809eba..114118f711 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -96,6 +96,7 @@ impl ChatWidget { if startup_has_content && self.local_settings.tui.vim_mode_default { self.bottom_pane.enable_vim_in_insert_mode(); } + crate::startup_recovery::handed_off(|| self.bottom_pane.composer_recovery_snapshot()); } /// Includes protected prompts deferred by streaming or the approval idle timer. diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 39d33426cc..03d7d25d22 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -423,6 +423,7 @@ impl ChatWidget { let submitted_image_display = (render_in_history && !local_images.is_empty()) .then(|| Self::user_message_display_from_inputs(&items)); let client_user_message_id = uuid::Uuid::new_v4().to_string(); + crate::startup_recovery::bind_submission(&text, &client_user_message_id); let pending_steer = (!render_in_history).then(|| PendingSteer { client_id: client_user_message_id.clone(), user_message: UserMessage { diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 205cd05902..c9bc3bd747 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -14,6 +14,9 @@ impl ChatWidget { } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { + if self.handle_startup_submission_key(key_event) { + return; + } if self.handle_question_key(key_event) { return; } @@ -160,6 +163,7 @@ impl ChatWidget { { if let Some(composer) = self.pop_latest_queued_composer_state() { self.restore_composer_state(composer); + self.refresh_startup_recovery(); self.refresh_pending_input_preview(); self.request_redraw(); } else { @@ -221,6 +225,13 @@ impl ChatWidget { let should_pause_active_goal = self.bottom_pane.should_interrupt_running_task(key_event); let input_result = self.bottom_pane.handle_key_event(key_event); + if matches!( + input_result, + InputResult::None | InputResult::ParentOwnedInputBlocked + ) { + self.refresh_startup_recovery(); + } + crate::startup_recovery::submitted(&input_result); self.sync_backend_banner_view(); if should_pause_active_goal { self.pause_active_goal_for_interrupt(); @@ -244,6 +255,7 @@ impl ChatWidget { } tracing::info!("attach_image path={path:?}"); self.bottom_pane.attach_image(path); + self.refresh_startup_recovery(); self.request_redraw(); } @@ -253,6 +265,7 @@ impl ChatWidget { pub(crate) fn apply_external_edit(&mut self, text: String) { self.bottom_pane.apply_external_edit(text); + self.refresh_startup_recovery(); self.request_redraw(); } @@ -498,12 +511,17 @@ impl ChatWidget { if self.external_writer_view && !self.bottom_pane.has_active_view() { return; } + if !self.startup_submission_has_protected_input() { + self.cancel_startup_submission(); + } self.bottom_pane.handle_paste(text); + self.refresh_startup_recovery(); } // Returns true if caller should skip rendering this frame (a future frame is scheduled). pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { if self.bottom_pane.flush_paste_burst_if_due() { + self.refresh_startup_recovery(); // A paste just flushed; request an immediate redraw and skip this frame. self.request_redraw(); true @@ -537,6 +555,7 @@ impl ChatWidget { KeyModifiers::CONTROL, )); if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { + self.refresh_startup_recovery(); if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if modal_or_popup_active { self.quit_shortcut_expires_at = None; diff --git a/codex-rs/tui/src/chatwidget/reconnect.rs b/codex-rs/tui/src/chatwidget/reconnect.rs index 4eb80b181b..310e875e1d 100644 --- a/codex-rs/tui/src/chatwidget/reconnect.rs +++ b/codex-rs/tui/src/chatwidget/reconnect.rs @@ -7,6 +7,7 @@ use crate::bottom_pane::RestrictedInputMode; impl ChatWidget { pub(crate) fn pause_for_disconnect(&mut self) { + self.cancel_startup_submission(); self.cancel_image_submission(); // The app-server transport can fail while the separate WebRTC helper // still sends microphone audio. Retire local media before showing offline UI. diff --git a/codex-rs/tui/src/chatwidget/startup_submission.rs b/codex-rs/tui/src/chatwidget/startup_submission.rs new file mode 100644 index 0000000000..836b063858 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/startup_submission.rs @@ -0,0 +1,123 @@ +//! Carry one startup submission intent across draft handoff without consuming editable text early. +//! +//! Confirmation applies to the visible draft only. Editing, cancellation, or disconnect removes +//! the intent; normal submission parsing runs once the session and protected-input gates are ready. + +use super::*; +use crate::bottom_pane::ComposerDraftSnapshot; + +impl ChatWidget { + /// Keep recovery current while startup still owns an unsent, handed-off draft. + pub(super) fn refresh_startup_recovery(&self) { + crate::startup_recovery::refresh_after_handoff(|| { + self.bottom_pane.composer_recovery_snapshot() + }); + } + + /// Restore startup text first, then submit its confirmed contents once the session is ready. + pub(crate) fn restore_startup_input_when_ready( + &mut self, + pending_draft: &mut Option, + pending_submission: &mut bool, + ) { + let had_draft = pending_draft.is_some(); + let destination_has_content = !self.bottom_pane.composer_is_empty() + || !self.bottom_pane.composer_pending_pastes().is_empty(); + self.restore_startup_draft_when_ready(pending_draft); + if self.blocks_direct_input { + *pending_submission = false; + self.cancel_startup_submission(); + return; + } + if had_draft + && pending_draft.is_none() + && std::mem::take(pending_submission) + && !destination_has_content + { + self.input_queue.startup_submission = Some(self.bottom_pane.composer_draft_snapshot()); + self.bottom_pane.set_footer_hint_override(Some(vec![ + ("Waiting for startup".into(), String::new()), + ("esc".into(), "cancel".into()), + ])); + } + if pending_draft.is_some() + || !self.is_session_configured() + || self + .effective_collaboration_mode() + .model() + .trim() + .is_empty() + || !self.bottom_pane.composer_input_enabled() + || self.startup_submission_has_protected_input() + || self.is_plan_streaming_in_tui() + || self.is_user_turn_pending_or_running() + || self.input_queue.suppress_queue_autosend + || self.input_queue.rate_limit_recovery_pending + || self.input_queue.recovered_queue + { + return; + } + #[cfg(any(target_os = "windows", test))] + if self.windows_sandbox_host == crate::app::WindowsSandboxHost::Local + && self.elevated_windows_sandbox_setup_required() + { + return; + } + let Some(confirmed) = self.input_queue.startup_submission.take() else { + crate::startup_recovery::clear(); + return; + }; + self.bottom_pane.set_footer_hint_override(/*items*/ None); + let current = self.bottom_pane.composer_draft_snapshot(); + if current.text != confirmed.text + || current.text_elements != confirmed.text_elements + || current.local_images != confirmed.local_images + || current.remote_image_urls != confirmed.remote_image_urls + || current.mention_bindings != confirmed.mention_bindings + || current.pending_pastes != confirmed.pending_pastes + { + return; + } + let result = self.bottom_pane.prepare_startup_submission(); + crate::startup_recovery::submitted(&result); + self.handle_composer_input_result(result, /*had_modal_or_popup*/ false); + } + + /// Cancel only the intent; the editor keeps the complete draft for editing or recovery. + pub(crate) fn cancel_startup_submission(&mut self) { + if self.input_queue.startup_submission.take().is_some() { + self.bottom_pane.set_footer_hint_override(/*items*/ None); + self.refresh_startup_recovery(); + } + } + + pub(super) fn handle_startup_submission_key(&mut self, key: KeyEvent) -> bool { + if self.input_queue.startup_submission.is_none() + || self.startup_submission_has_protected_input() + || !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) + { + return false; + } + if self.bottom_pane.is_startup_submit_key(key) { + return true; + } + if key.code == KeyCode::Esc { + self.cancel_startup_submission(); + return true; + } + if !self.bottom_pane.is_startup_cursor_key(key) { + self.cancel_startup_submission(); + } + false + } + + pub(super) fn startup_submission_has_protected_input(&self) -> bool { + self.has_active_view() + || self + .bottom_pane + .questions + .as_ref() + .is_some_and(|q| q.expanded) + || self.has_pending_protected_request() + } +} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index dd92dbb56b..e12b20257d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -292,6 +292,8 @@ mod side; mod slash_commands; #[path = "tests/sparkle_submission_tests.rs"] mod sparkle_submission_tests; +#[path = "tests/startup_submission_tests.rs"] +mod startup_submission_tests; mod status_and_layout; mod status_command_tests; mod status_surface_previews; diff --git a/codex-rs/tui/src/chatwidget/tests/startup_submission_tests.rs b/codex-rs/tui/src/chatwidget/tests/startup_submission_tests.rs new file mode 100644 index 0000000000..1d967d5789 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/startup_submission_tests.rs @@ -0,0 +1,137 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn startup_submission_waits_for_session_and_sends_exactly_once() { + let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + source.bottom_pane.insert_str("confirmed before startup"); + let mut draft = Some(source.bottom_pane.composer_draft_snapshot()); + let mut confirmed = true; + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.restore_startup_input_when_ready(&mut draft, &mut confirmed); + assert!(draft.is_none()); + assert_eq!(chat.bottom_pane.composer_text(), "confirmed before startup"); + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + } + for key in [ + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), + ] { + chat.handle_key_event(key); + } + assert_no_submit_op(&mut op_rx); + chat.thread_id = Some(ThreadId::new()); + let model = chat.effective_collaboration_mode().model().to_string(); + chat.set_model(""); + chat.restore_startup_input_when_ready(&mut draft, &mut confirmed); + assert_no_submit_op(&mut op_rx); + assert_eq!(chat.bottom_pane.composer_text(), "confirmed before startup"); + chat.set_model(&model); + chat.restore_startup_input_when_ready(&mut draft, &mut confirmed); + assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { items, .. } if items == vec![UserInput::Text { + text: "confirmed before startup".into(), text_elements: Vec::new(), + }]); + chat.restore_startup_input_when_ready(&mut draft, &mut confirmed); + assert_no_submit_op(&mut op_rx); + assert_eq!(chat.bottom_pane.composer_text(), ""); +} + +#[tokio::test] +async fn startup_submission_escape_restores_editing_without_submitting() { + let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + source.bottom_pane.insert_str("keep the draft"); + source.bottom_pane.set_composer_cursor(/*cursor*/ 4); + let mut draft = Some(source.bottom_pane.composer_draft_snapshot()); + let mut confirmed = true; + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.restore_startup_input_when_ready(&mut draft, &mut confirmed); + let before = chat.bottom_pane.composer_draft_snapshot(); + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.thread_id = Some(ThreadId::new()); + chat.restore_startup_input_when_ready(&mut draft, &mut confirmed); + assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn startup_submission_waits_for_protected_view_and_preserves_paste_provenance() { + for payload in [ + " !echo hi\n# visible tail".to_string(), + format!("!echo {}", "x".repeat(/*n*/ 1000)), + ] { + let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + source.handle_paste(payload.clone()); + let mut draft = Some(source.bottom_pane.composer_draft_snapshot()); + let mut confirmed = true; + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.open_approvals_popup(); + chat.restore_startup_input_when_ready(&mut draft, &mut confirmed); + assert!(draft.is_some()); + assert_no_submit_op(&mut op_rx); + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.restore_startup_input_when_ready(&mut draft, &mut confirmed); + if payload.starts_with(' ') { + assert!(op_rx.try_recv().is_err()); + assert_eq!(chat.bottom_pane.composer_text(), payload); + chat.handle_key_event(KeyCode::Enter.into()); + assert_matches!(op_rx.try_recv(), Ok(Op::RunUserShellCommand { command }) + if command == payload.trim_start().trim_start_matches('!')); + } else { + assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { items, .. } if items == vec![UserInput::Text { + text: payload, text_elements: Vec::new(), + }]); + } + assert_no_submit_op(&mut op_rx); + } +} + +#[tokio::test] +async fn startup_submission_edit_or_disconnect_cancels_without_losing_draft() { + for action in ["edit", "disconnect", "parent-owned"] { + let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + source.bottom_pane.insert_str("confirmed draft"); + let mut draft = Some(source.bottom_pane.composer_draft_snapshot()); + let mut confirmed = true; + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + if action == "parent-owned" { + chat.set_parent_owned_thread(); + } + chat.restore_startup_input_when_ready(&mut draft, &mut confirmed); + if action == "disconnect" { + chat.pause_for_disconnect(); + } else if action == "edit" { + chat.handle_paste(" edited".into()); + } + chat.thread_id = Some(ThreadId::new()); + chat.restore_startup_input_when_ready(&mut draft, &mut confirmed); + assert_eq!( + chat.bottom_pane.composer_text(), + if action != "edit" { + "confirmed draft" + } else { + "confirmed draft edited" + } + ); + assert!(chat.input_queue.startup_submission.is_none()); + assert_no_submit_op(&mut op_rx); + } +} + +#[tokio::test] +async fn startup_submission_does_not_confirm_merged_destination_text() { + let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + source.bottom_pane.insert_str("startup draft"); + let mut draft = Some(source.bottom_pane.composer_draft_snapshot()); + let mut confirmed = true; + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.insert_str("existing draft"); + chat.thread_id = Some(ThreadId::new()); + chat.restore_startup_input_when_ready(&mut draft, &mut confirmed); + assert_eq!( + chat.bottom_pane.composer_text(), + "existing draft\nstartup draft" + ); + assert_no_submit_op(&mut op_rx); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index aecee78f49..d3de073e04 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -73,6 +73,7 @@ use codex_utils_home_dir::find_codex_home; use codex_utils_oss::ensure_oss_provider_ready; use codex_utils_oss::get_default_model_for_oss_provider; use color_eyre::eyre::WrapErr; +use crossterm::SynchronizedUpdate; use cwd_prompt::CwdPromptAction; pub use session_archive_commands::DeleteConfirmation; pub use session_archive_commands::SessionArchiveAction; @@ -200,6 +201,8 @@ mod startup_error; mod startup_hooks_review; mod startup_orchestration; mod startup_preflight; +mod startup_presentation; +mod startup_recovery; mod status; mod status_indicator_widget; mod streaming; @@ -595,14 +598,26 @@ pub(crate) async fn start_app_server_for_picker( pub(crate) async fn start_embedded_app_server_for_picker( config: &Config, ) -> color_eyre::Result { - let state_db = init_state_db_for_app_server_target(config, &AppServerTarget::Embedded).await?; - start_app_server_for_picker( - config, - &AppServerTarget::Embedded, - state_db, + let mut target = AppServerTarget::Embedded; + let mut state_db = init_state_db_for_app_server_target(config, &target).await?; + let app_server = start_app_server( + &mut target, + Arg0DispatchPaths::default(), + config.clone(), + Vec::new(), + LoaderOverrides::without_managed_config_for_tests(), + /*strict_config*/ false, + CloudConfigBundleLoader::default(), + codex_feedback::CodexFeedback::new(), + /*log_db*/ None, + &mut state_db, Arc::new(EnvironmentManager::default_for_tests()), ) - .await + .await?; + Ok( + AppServerSession::new(app_server, target.thread_params_mode()) + .with_local_codex_home(&config.codex_home), + ) } #[allow(clippy::too_many_arguments)] @@ -1015,6 +1030,7 @@ fn restore_terminal_before_fatal_exit() { if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) { let _ = tui::restore_after_exit(); } + startup_recovery::print_unsent_draft(); } pub async fn run_main( @@ -1024,25 +1040,37 @@ pub async fn run_main( explicit_remote_endpoint: Option, ) -> std::io::Result { system_motion::initialize().await; - // Keep the startup future out of the CLI caller's frame while the TUI is running. - match Box::pin(startup_orchestration::run_main_inner( - cli, - arg0_paths, - loader_overrides, - explicit_remote_endpoint, - )) + startup_recovery::scope(async move { + // Startup retains a large future for the whole session. Keep it off callers' stacks, + // which also need room to construct a replacement chat widget on `/new`. + match Box::pin(startup_orchestration::run_main_inner( + cli, + arg0_paths, + loader_overrides, + explicit_remote_endpoint, + )) + .await + { + Err(err) if startup_draft::StartupCancelled::matches(&err) => Ok(AppExitInfo { + token_usage: TokenUsage::default(), + thread_id: None, + resume_hint: None, + disconnect_info: None, + update_action: None, + exit_reason: ExitReason::UserRequested, + }), + Err(err) => { + restore_terminal_before_fatal_exit(); + Err(err) + } + Ok(info) if matches!(&info.exit_reason, ExitReason::Fatal(_)) => { + restore_terminal_before_fatal_exit(); + Ok(info) + } + result => result, + } + }) .await - { - Err(err) if startup_draft::StartupCancelled::matches(&err) => Ok(AppExitInfo { - token_usage: TokenUsage::default(), - thread_id: None, - resume_hint: None, - disconnect_info: None, - update_action: None, - exit_reason: ExitReason::UserRequested, - }), - result => result, - } } #[allow(clippy::too_many_arguments)] @@ -1429,11 +1457,13 @@ async fn run_ratatui_app( let Some(app_server) = app_server.take() else { unreachable!("app server should be initialized for --fork picker"); }; + let picker_local_settings = + crate::local_settings::LocalSettings::for_tui(&config, &tui); match resume_picker::run_fork_picker_with_app_server( uses_remote_workspace_or_environment(&app_server_target, &environment_manager), &mut tui, &config, - &crate::local_settings::LocalSettings::from(&config), + &picker_local_settings, cli.fork_show_all, app_server, ) @@ -1526,11 +1556,12 @@ async fn run_ratatui_app( let Some(app_server) = app_server.take() else { unreachable!("app server should be initialized for --resume picker"); }; + let picker_local_settings = crate::local_settings::LocalSettings::for_tui(&config, &tui); match resume_picker::run_resume_picker_with_app_server( uses_remote_workspace_or_environment(&app_server_target, &environment_manager), &mut tui, &config, - &crate::local_settings::LocalSettings::from(&config), + &picker_local_settings, cli.resume_show_all, cli.resume_include_non_interactive, app_server, @@ -1809,7 +1840,21 @@ async fn run_ratatui_app( } startup_draft.apply_config(&config); - let local_settings = crate::local_settings::LocalSettings::from(&config); + // Cloud configuration and session selection can change screen policy after first paint. + let use_alt_screen = determine_alt_screen_mode(cli.no_alt_screen, config.tui_alternate_screen); + let mode = crate::transcript_mode::TranscriptMode::resolve( + config.features.enabled(Feature::TranscriptV2), + use_alt_screen, + ); + if use_alt_screen != tui.is_alt_screen_enabled() || mode.is_owned() != tui.is_owned_screen() { + std::io::stdout().sync_update(|_| { + tui.set_alt_screen_enabled(use_alt_screen); + tui.set_owned_screen(mode.is_owned())?; + startup_draft.redraw_if_visible(&mut tui) + })??; + } + + let local_settings = crate::local_settings::LocalSettings::for_tui(&config, &tui); // Configure syntax highlighting theme from the final config — onboarding // and resume/fork can both reload config with a different tui_theme, so // this must happen after the last possible reload. @@ -1830,15 +1875,11 @@ async fn run_ratatui_app( let Cli { prompt, shared, - no_alt_screen, daemon_cli_executable, .. } = cli; let images = shared.into_inner().images; - let use_alt_screen = - determine_alt_screen_mode(no_alt_screen, local_settings.tui.alternate_screen); - tui.set_alt_screen_enabled(use_alt_screen); // Persistent app-server resumes may attach to an already-running thread, // where resume config overrides are ignored. let is_persistent_resume = !matches!(&app_server_target, AppServerTarget::Embedded) 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 new file mode 100644 index 0000000000..9de37490a3 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__layout__tests__owned_startup_layout.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/startup_draft_layout_tests.rs +expression: startup_layout +--- +cursor=Some((13, 12)) +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + + + + +› first line + second line + + + Waiting for startup · esc cancel diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index faedbf0a5c..f7efd7937c 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -1,4 +1,5 @@ -//! Display an editable, non-submitting composer while startup work continues. +//! Keep the first composer editable and bottom-anchored while startup work continues. +//! Submit keys confirm one draft locally; session dispatch waits for the protected handoff. use std::future::Future; use std::io; @@ -8,10 +9,7 @@ use std::task::Poll; use std::time::Duration; use std::time::Instant; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; +use crossterm::SynchronizedUpdate; use ratatui::layout::Size; use ratatui::style::Modifier; use ratatui::style::Style; @@ -31,7 +29,6 @@ use crate::bottom_pane::ChatComposerConfig; use crate::bottom_pane::ComposerDraftSnapshot; use crate::history_cell; use crate::history_cell::HistoryCell; -use crate::key_hint; use crate::keymap::RuntimeKeymap; use crate::legacy_core::config::Config; use crate::render::Insets; @@ -49,6 +46,23 @@ use crate::version::CODEX_CLI_VERSION; const STARTUP_EVENT_BATCH_SIZE: usize = 64; const STARTUP_PASTE_NEWLINE_TIMEOUT: Duration = Duration::from_millis(120); +#[path = "startup_draft_layout.rs"] +mod layout; + +#[path = "startup_draft_input.rs"] +mod input; +#[cfg(test)] +use input::handle_startup_draft_key; + +/// Locally resolved presentation used before the first editable frame. +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) keymap: RuntimeKeymap, + pub(crate) disable_paste_burst: bool, +} + /// Identifies the first interactive surface expected for the current invocation. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum StartupDraftInitialScreen { @@ -61,7 +75,6 @@ pub(crate) enum StartupDraftInitialScreen { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum StartupDraftSessionAction { New, - /// Keep in-app creation alive while allowing draft edits until setup finishes. NewFromCommandCenter, Resume, Fork, @@ -87,7 +100,7 @@ pub(crate) struct StartupDraft { pump: StartupDraftPump, } -/// Keeps the existing terminal responsive without owning it or permitting startup submission. +/// Keeps terminal input responsive and carries one submission intent into the live session. pub(crate) struct StartupDraftPump { header: Box, bottom_pane: BottomPane, @@ -95,31 +108,50 @@ pub(crate) struct StartupDraftPump { app_event_rx: UnboundedReceiver, initial_screen: StartupDraftInitialScreen, session_action: StartupDraftSessionAction, + resolved_selection: Option, + configured_cwd: Option, pending_paste_newline: Option<(Instant, String)>, + submission_pending: bool, + key_chord_matcher: crate::keymap::KeyChordMatcher, + key_chords: std::sync::Arc, } impl StartupDraft { - /// Initialize the terminal without showing a composer before an expected session picker. + /// Adopt the initialized terminal and apply editing policy before the first composer paint. pub(crate) fn new( + mut initialized_terminal: tui::InitializedTerminal, + terminal_restore_guard: TerminalRestoreGuard, initial_screen: StartupDraftInitialScreen, session_action: StartupDraftSessionAction, + screen: StartupScreen, ) -> io::Result { - let mut initialized_terminal = tui::init()?; - let terminal_restore_guard = TerminalRestoreGuard::new(); initialized_terminal.terminal.clear()?; - let tui = Tui::new( + let mut tui = Tui::new( initialized_terminal.terminal, initialized_terminal.enhanced_keys_supported, initialized_terminal.stderr_guard, ); - let pump = StartupDraftPump::new(&tui, initial_screen, session_action); + tui.set_alt_screen_enabled(screen.use_alt_screen); + let mut pump = StartupDraftPump::new(&tui, initial_screen, session_action); + pump.bottom_pane + .set_status_line_enabled(screen.status_line_enabled); + pump.bottom_pane.set_keymap_bindings(&screen.keymap); + pump.bottom_pane + .set_disable_paste_burst(screen.disable_paste_burst); + pump.key_chords = screen.keymap.chords; let mut draft = Self { tui, terminal_restore_guard, pump, }; - draft.pump.show_initial_screen(&mut draft.tui)?; + // Present the screen switch and its first composer frame together. + std::io::stdout().sync_update(|_| { + draft + .tui + .set_owned_screen(screen.transcript_mode.is_owned())?; + draft.pump.redraw_if_visible(&mut draft.tui) + })??; Ok(draft) } @@ -143,6 +175,7 @@ impl StartupDraft { /// Lend the original terminal to an existing interactive startup screen. pub(crate) fn tui_mut(&mut self) -> &mut Tui { + self.pump.key_chord_matcher.cancel(); &mut self.tui } @@ -171,9 +204,19 @@ impl StartupDraftPump { initial_screen, session_action, pending_paste_newline: None, + resolved_selection: None, + configured_cwd: None, + submission_pending: false, + key_chord_matcher: Default::default(), + key_chords: RuntimeKeymap::defaults().chords, } } + pub(crate) fn take_draft(&mut self) -> ComposerDraftSnapshot { + self.bottom_pane.flush_composer_paste_burst(); + self.bottom_pane.composer_draft_snapshot() + } + /// Keep a provisional composer responsive when the caller has one to display. pub(crate) async fn run_with_optional_draft( draft: Option<&mut Self>, @@ -192,17 +235,39 @@ impl StartupDraftPump { /// Refresh the session header and safe editor shortcuts without enabling modal editing. pub(crate) fn apply_config(&mut self, config: &Config) { + if self + .configured_cwd + .as_deref() + .is_some_and(|cwd| cwd != config.cwd.as_path()) + { + self.cancel_submission(); + } + self.configured_cwd = Some(config.cwd.to_path_buf()); let local_settings = crate::local_settings::LocalSettings::from(config); self.header = startup_session_header(Some(config)); + self.bottom_pane.set_status_line_enabled( + local_settings + .tui + .status_line + .as_ref() + .is_none_or(|items| !items.is_empty()), + ); self.bottom_pane .set_disable_paste_burst(local_settings.tui.disable_paste_burst.unwrap_or(false)); self.bottom_pane.request_redraw(); if let Ok(keymap) = RuntimeKeymap::from_config(&local_settings.tui.keymap) { + if crate::keymap::keymap_action_id("composer", "submit").is_none_or(|submit| { + self.key_chords.configured_specs(submit) != keymap.chords.configured_specs(submit) + }) { + self.cancel_submission(); + } self.bottom_pane.set_keymap_bindings(&keymap); + self.key_chord_matcher.cancel(); + self.key_chords = keymap.chords; } } - /// Align the provisional loading message with the resolved session selection. + /// Align the loading message and revoke confirmation when startup changes its destination. pub(crate) fn update_session_selection( &mut self, tui: &mut Tui, @@ -215,9 +280,31 @@ impl StartupDraftPump { SessionSelection::Resume(_) => StartupDraftSessionAction::Resume, SessionSelection::Fork(_) => StartupDraftSessionAction::Fork, }; - if self.session_action == session_action { - return Ok(()); + let destination_changed = match self.resolved_selection.as_ref() { + Some(SessionSelection::StartFresh) => { + !matches!(session_selection, SessionSelection::StartFresh) + } + Some(SessionSelection::AgentsOverview) => { + !matches!(session_selection, SessionSelection::AgentsOverview) + } + Some(SessionSelection::Exit) => !matches!(session_selection, SessionSelection::Exit), + Some(SessionSelection::Resume(previous)) => { + !matches!(session_selection, SessionSelection::Resume(next) if previous.thread_id == next.thread_id && previous.cwd == next.cwd) + } + Some(SessionSelection::Fork(previous)) => { + !matches!(session_selection, SessionSelection::Fork(next) if previous.thread_id == next.thread_id && previous.cwd == next.cwd) + } + None => self.session_action != session_action, + }; + if destination_changed + || matches!( + session_selection, + SessionSelection::AgentsOverview | SessionSelection::Exit + ) + { + self.cancel_submission(); } + self.resolved_selection = Some(session_selection.clone()); self.session_action = session_action; if self.initial_screen == StartupDraftInitialScreen::Composer { self.draw(tui, tui.terminal.last_known_screen_size)?; @@ -289,16 +376,24 @@ impl StartupDraftPump { /// Preserve the editable draft, its cursor, and any pending large-paste placeholders. pub(crate) fn into_draft(mut self) -> ComposerDraftSnapshot { - self.take_draft() + let draft = self.take_draft(); + crate::startup_recovery::remember(draft.clone()); + draft } - pub(crate) fn take_draft(&mut self) -> ComposerDraftSnapshot { - self.bottom_pane.flush_composer_paste_burst(); - self.bottom_pane.composer_draft_snapshot() + /// Transfer the single confirmed submission without removing its visible draft. + pub(crate) fn take_submission_intent(&mut self) -> bool { + std::mem::take(&mut self.submission_pending) } - /// Draw the initial composer only when no protected startup screen must appear first. - fn show_initial_screen(&mut self, tui: &mut Tui) -> io::Result<()> { + fn cancel_submission(&mut self) { + if std::mem::take(&mut self.submission_pending) { + self.bottom_pane.set_footer_hint_override(/*items*/ None); + } + } + + /// Redraw the composer without revealing it while a protected startup screen owns input. + pub(crate) fn redraw_if_visible(&mut self, tui: &mut Tui) -> io::Result<()> { if self.initial_screen == StartupDraftInitialScreen::Composer { self.show(tui)?; } @@ -311,113 +406,28 @@ impl StartupDraftPump { self.draw(tui, tui.terminal.last_known_screen_size) } - fn handle_event(&mut self, tui: &mut Tui, event: TuiEvent) -> io::Result<()> { - let screen_size = tui.screen_size_for_event(&event)?; - if let Some((started_at, mut newlines)) = self.pending_paste_newline.take() { - let continues_paste = match &event { - TuiEvent::Key(KeyEvent { - code: KeyCode::Char(_), - modifiers, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => !key_hint::has_ctrl_or_alt(*modifiers), - TuiEvent::Key(KeyEvent { - code: KeyCode::Enter, - modifiers, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) if modifiers.is_empty() => { - if started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT { - newlines.push('\n'); - self.pending_paste_newline = Some((Instant::now(), newlines)); - } - return Ok(()); - } - TuiEvent::Paste(text) => !text.is_empty(), - TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => { - self.pending_paste_newline = Some((started_at, newlines)); - if self.initial_screen == StartupDraftInitialScreen::Composer { - self.draw(tui, screen_size)?; - } - return Ok(()); - } - TuiEvent::Mouse(_) | TuiEvent::FocusLost => { - self.pending_paste_newline = Some((started_at, newlines)); - return Ok(()); - } - TuiEvent::Key(_) => false, - }; - if continues_paste && started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT { - self.bottom_pane.handle_paste(newlines); - } - } - match event { - TuiEvent::Key(key) => { - if self.initial_screen != StartupDraftInitialScreen::Composer - && !key_hint::ctrl(KeyCode::Char('c')).is_press(key) - && !key_hint::ctrl(KeyCode::Char('d')).is_press(key) - { - return Ok(()); - } - if key.code == KeyCode::Enter - && key.modifiers.is_empty() - && self.bottom_pane.is_in_paste_burst() - { - let text_len = self.bottom_pane.composer_text().len(); - self.bottom_pane.flush_composer_paste_burst(); - if self - .bottom_pane - .composer_text() - .len() - .saturating_sub(text_len) - > 1 - { - self.pending_paste_newline = Some((Instant::now(), "\n".to_string())); - } - } - match handle_startup_draft_key(&mut self.bottom_pane, key) { - Err(error) if StartupCancelled::matches(&error) => { - // In-app setup owns a live creation request. Keep pumping input - // instead of abandoning it or blocking on cancellation cleanup. - if self.session_action != StartupDraftSessionAction::NewFromCommandCenter { - if let Err(clear_error) = tui.terminal.clear() { - tracing::warn!( - error = %clear_error, - "failed to clear the cancelled startup composer" - ); - } - return Err(error); - } - } - result => result?, - } - } - TuiEvent::Paste(text) => { - if self.initial_screen == StartupDraftInitialScreen::Composer { - self.bottom_pane.flush_composer_paste_burst(); - self.bottom_pane.handle_paste(text); - } - } - TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {} - TuiEvent::Mouse(_) | TuiEvent::FocusLost => return Ok(()), - } - if self.initial_screen == StartupDraftInitialScreen::Composer { - self.draw(tui, screen_size)?; - } - while self.app_event_rx.try_recv().is_ok() {} - Ok(()) - } - fn draw(&mut self, tui: &mut Tui, screen_size: Size) -> io::Result<()> { - let _ = self.bottom_pane.flush_paste_burst_if_due(); + if self.bottom_pane.flush_paste_burst_if_due() { + crate::startup_recovery::remember(self.bottom_pane.composer_recovery_snapshot()); + } if self.bottom_pane.is_in_paste_burst() { tui.frame_requester() .schedule_frame_in(ChatComposer::recommended_paste_flush_delay()); } self.bottom_pane.pre_draw_tick(); - let renderable = - startup_draft_renderable(&self.header, &self.bottom_pane, self.session_action); - let desired_height = renderable.desired_height(screen_size.width); + let owned = tui.is_owned_screen(); + let owned_layout = + layout::OwnedStartupLayout::new(&self.header, &self.bottom_pane, self.session_action); + let renderable = if owned { + RenderableItem::Borrowed(&owned_layout) + } else { + startup_draft_renderable(&self.header, &self.bottom_pane, self.session_action) + }; + let desired_height = if owned { + screen_size.height + } else { + renderable.desired_height(screen_size.width) + }; tui.draw_with_resize_reflow(desired_height, screen_size, |frame| { let area = frame.area(); renderable.render(area, frame.buffer); @@ -425,66 +435,11 @@ impl StartupDraftPump { frame.set_cursor_style(renderable.cursor_style(area)); frame.set_cursor_position((x, y)); } - }) + })?; + Ok(()) } } -fn handle_startup_draft_key(bottom_pane: &mut BottomPane, key: KeyEvent) -> io::Result<()> { - let _ = bottom_pane.flush_paste_burst_if_due(); - if key.kind == KeyEventKind::Release - || key.code == KeyCode::Enter && key.modifiers.is_empty() - || matches!(key.code, KeyCode::Tab | KeyCode::BackTab) - || bottom_pane.is_startup_composer_action(key) - { - return Ok(()); - } - - if let KeyCode::Char(_) = key.code { - let (code, modifiers) = key_hint::normalize_key_parts(key.code, key.modifiers); - if key_hint::has_ctrl_or_alt(modifiers) && matches!(code, KeyCode::Char('r' | 'v')) { - return Ok(()); - } - let is_ctrl_c = key_hint::ctrl(KeyCode::Char('c')).is_press(key); - let is_ctrl_d = key_hint::ctrl(KeyCode::Char('d')).is_press(key); - if key.kind == KeyEventKind::Press && (is_ctrl_c || is_ctrl_d) { - bottom_pane.flush_composer_paste_burst(); - if bottom_pane.composer_is_empty() { - return Err(io::Error::new(io::ErrorKind::Interrupted, StartupCancelled)); - } - if is_ctrl_c { - bottom_pane.on_ctrl_c(); - return Ok(()); - } - } - } - - if key_hint::has_ctrl_or_alt(key.modifiers) && !bottom_pane.is_safe_startup_editor_key(key) - || key.code == KeyCode::Enter && !bottom_pane.is_safe_startup_editor_key(key) - || key - .modifiers - .intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META) - || !matches!( - key.code, - KeyCode::Char(_) - | KeyCode::Enter - | KeyCode::Esc - | KeyCode::Left - | KeyCode::Right - | KeyCode::Up - | KeyCode::Down - | KeyCode::Home - | KeyCode::End - | KeyCode::Backspace - | KeyCode::Delete - ) - { - return Ok(()); - } - - let _ = bottom_pane.handle_key_event(key); - Ok(()) -} - fn startup_session_header(config: Option<&Config>) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); let directory = config.map_or_else( @@ -525,7 +480,7 @@ fn startup_draft_renderable<'a>( renderable.push( /*flex*/ 0, bottom_pane - .as_renderable_with_options(Default::default()) + .as_renderable_with_options(crate::bottom_pane::ComposerRenderOptions::default()) .inset(Insets::tlbr( /*top*/ u16::from(loading_message.is_none()), /*left*/ 0, @@ -561,3 +516,7 @@ fn startup_draft_bottom_pane( #[cfg(test)] #[path = "startup_draft_tests.rs"] pub(crate) mod tests; + +#[cfg(test)] +#[path = "startup_draft_submission_tests.rs"] +mod submission_tests; diff --git a/codex-rs/tui/src/startup_draft_input.rs b/codex-rs/tui/src/startup_draft_input.rs new file mode 100644 index 0000000000..b3ddf27318 --- /dev/null +++ b/codex-rs/tui/src/startup_draft_input.rs @@ -0,0 +1,237 @@ +//! Edit and confirm the provisional startup draft without executing actions before initialization. +//! +//! Terminal input stays with the original pump through startup. Paste-newline ambiguity is +//! preserved, protected screens do not consume draft input, and confirmation never removes text. + +use std::io; +use std::time::Instant; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; + +use super::STARTUP_PASTE_NEWLINE_TIMEOUT; +use super::StartupCancelled; +use super::StartupDraftInitialScreen; +use super::StartupDraftPump; +use crate::bottom_pane::BottomPane; +use crate::key_hint; +use crate::tui::Tui; +use crate::tui::TuiEvent; + +impl StartupDraftPump { + pub(super) fn handle_event(&mut self, tui: &mut Tui, event: TuiEvent) -> io::Result<()> { + let screen_size = tui.screen_size_for_event(&event)?; + if let Some((started_at, mut newlines)) = self.pending_paste_newline.take() { + let continues_paste = match &event { + TuiEvent::Key(KeyEvent { + code: KeyCode::Char(_), + modifiers, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => !key_hint::has_ctrl_or_alt(*modifiers), + TuiEvent::Key(KeyEvent { + code: KeyCode::Enter, + modifiers, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) if modifiers.is_empty() => { + if started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT { + newlines.push('\n'); + self.pending_paste_newline = Some((Instant::now(), newlines)); + return Ok(()); + } + false + } + TuiEvent::Paste(text) => !text.is_empty(), + TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => { + self.pending_paste_newline = Some((started_at, newlines)); + if self.initial_screen == StartupDraftInitialScreen::Composer { + self.draw(tui, screen_size)?; + } + return Ok(()); + } + TuiEvent::FocusLost | TuiEvent::Mouse(_) => { + self.pending_paste_newline = Some((started_at, newlines)); + return Ok(()); + } + TuiEvent::Key(_) => false, + }; + if continues_paste && started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT { + self.bottom_pane.handle_paste(newlines); + } + } + match event { + TuiEvent::Key(key) => { + if self.initial_screen != StartupDraftInitialScreen::Composer + && !key_hint::ctrl(KeyCode::Char('c')).is_press(key) + && !key_hint::ctrl(KeyCode::Char('d')).is_press(key) + { + return Ok(()); + } + let key = match self.key_chord_matcher.advance( + key, + &self.key_chords, + self.bottom_pane.keymap_contexts(), + ) { + crate::keymap::KeyChordMatch::Completed(key) => key, + crate::keymap::KeyChordMatch::PassThrough => key, + crate::keymap::KeyChordMatch::Pending(_) + | crate::keymap::KeyChordMatch::Cancelled + | crate::keymap::KeyChordMatch::Ignored => return Ok(()), + }; + if key.code == KeyCode::Enter + && key.modifiers.is_empty() + && self.bottom_pane.is_in_paste_burst() + { + let text_len = self.bottom_pane.composer_text().len(); + 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)) + { + 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.bottom_pane.is_startup_submit_key(key) + { + self.bottom_pane.flush_composer_paste_burst(); + if self.pending_paste_newline.is_none() + && !self.bottom_pane.composer_text().trim().is_empty() + { + self.submission_pending = true; + self.bottom_pane.set_footer_hint_override(Some(vec![ + ("Waiting for startup".into(), String::new()), + ("esc".into(), "cancel".into()), + ])); + } + crate::startup_recovery::remember( + self.bottom_pane.composer_recovery_snapshot(), + ); + self.draw(tui, screen_size)?; + return Ok(()); + } + if self.submission_pending + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) + && key.code == KeyCode::Esc + { + self.cancel_submission(); + self.draw(tui, screen_size)?; + return Ok(()); + } + 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 + && StartupCancelled::matches(&error)) + { + if StartupCancelled::matches(&error) + && let Err(clear_error) = tui.terminal.clear() + { + tracing::warn!( + error = %clear_error, + "failed to clear the cancelled startup composer" + ); + } + return Err(error); + } + if self.bottom_pane.composer_text() != previous_text + || self.bottom_pane.is_in_paste_burst() + { + self.cancel_submission(); + } + } + TuiEvent::Paste(text) => { + self.key_chord_matcher.cancel(); + if self.initial_screen == StartupDraftInitialScreen::Composer { + self.cancel_submission(); + self.bottom_pane.flush_composer_paste_burst(); + self.bottom_pane.handle_paste(text); + } + } + TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {} + TuiEvent::FocusLost => { + self.key_chord_matcher.cancel(); + return Ok(()); + } + TuiEvent::Mouse(_) => return Ok(()), + } + crate::startup_recovery::remember(self.bottom_pane.composer_recovery_snapshot()); + if self.initial_screen == StartupDraftInitialScreen::Composer { + self.draw(tui, screen_size)?; + } + while self.app_event_rx.try_recv().is_ok() {} + Ok(()) + } +} + +pub(super) fn handle_startup_draft_key( + bottom_pane: &mut BottomPane, + key: KeyEvent, +) -> io::Result<()> { + let _ = bottom_pane.flush_paste_burst_if_due(); + if key.kind == KeyEventKind::Release + || key.code == KeyCode::Enter + && key.modifiers.is_empty() + && !bottom_pane.is_safe_startup_editor_key(key) + || matches!(key.code, KeyCode::Tab | KeyCode::BackTab) + || bottom_pane.is_startup_composer_action(key) + { + return Ok(()); + } + + if let KeyCode::Char(_) = key.code { + let (code, modifiers) = key_hint::normalize_key_parts(key.code, key.modifiers); + if key_hint::has_ctrl_or_alt(modifiers) && matches!(code, KeyCode::Char('r' | 'v')) { + return Ok(()); + } + let is_ctrl_c = key_hint::ctrl(KeyCode::Char('c')).is_press(key); + let is_ctrl_d = key_hint::ctrl(KeyCode::Char('d')).is_press(key); + if key.kind == KeyEventKind::Press && (is_ctrl_c || is_ctrl_d) { + bottom_pane.flush_composer_paste_burst(); + if bottom_pane.composer_is_empty() { + return Err(io::Error::new(io::ErrorKind::Interrupted, StartupCancelled)); + } + if is_ctrl_c { + bottom_pane.on_ctrl_c(); + return Ok(()); + } + } + } + + if key_hint::has_ctrl_or_alt(key.modifiers) && !bottom_pane.is_safe_startup_editor_key(key) + || key.code == KeyCode::Enter && !bottom_pane.is_safe_startup_editor_key(key) + || key + .modifiers + .intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META) + || !bottom_pane.is_safe_startup_editor_key(key) + && !matches!( + key.code, + KeyCode::Char(_) + | KeyCode::Enter + | KeyCode::Esc + | KeyCode::Left + | KeyCode::Right + | KeyCode::Up + | KeyCode::Down + | KeyCode::Home + | KeyCode::End + | KeyCode::Backspace + | KeyCode::Delete + ) + { + return Ok(()); + } + + let _ = bottom_pane.handle_key_event(key); + Ok(()) +} diff --git a/codex-rs/tui/src/startup_draft_layout.rs b/codex-rs/tui/src/startup_draft_layout.rs new file mode 100644 index 0000000000..ed64e73710 --- /dev/null +++ b/codex-rs/tui/src/startup_draft_layout.rs @@ -0,0 +1,99 @@ +//! 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. + +use crossterm::cursor::SetCursorStyle; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; + +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; + +pub(super) struct OwnedStartupLayout<'a> { + header: &'a dyn Renderable, + bottom: RenderableItem<'a>, + session_action: StartupDraftSessionAction, +} + +impl<'a> OwnedStartupLayout<'a> { + pub(super) fn new( + header: &'a dyn Renderable, + bottom_pane: &'a BottomPane, + session_action: StartupDraftSessionAction, + ) -> Self { + Self { + header, + bottom: bottom_pane.as_renderable_with_options(ComposerRenderOptions { + separate_status_line: true, + command_popup_placement: CommandPopupPlacement::Overlay, + ..ComposerRenderOptions::default() + }), + session_action, + } + } + + pub(super) fn bottom_area(&self, area: Rect) -> Rect { + let height = self.bottom.desired_height(area.width).min(area.height); + Rect { + y: area.bottom().saturating_sub(height), + height, + ..area + } + } +} + +impl Renderable for OwnedStartupLayout<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + let bottom = self.bottom_area(area); + let header = Rect { + height: self + .header + .desired_height(area.width) + .min(bottom.y.saturating_sub(area.y)), + ..area + }; + self.header.render(header, buf); + let message = match self.session_action { + StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => { + None + } + StartupDraftSessionAction::Resume => Some(" Resuming session…"), + StartupDraftSessionAction::Fork => Some(" Forking session…"), + }; + if let Some(message) = message + && header.bottom() < bottom.y + { + message.dim().render( + Rect { + y: header.bottom(), + height: 1, + ..area + }, + buf, + ); + } + self.bottom.render(bottom, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.header + .desired_height(width) + .saturating_add(self.bottom.desired_height(width)) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.bottom.cursor_pos(self.bottom_area(area)) + } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + self.bottom.cursor_style(self.bottom_area(area)) + } +} + +#[cfg(test)] +#[path = "startup_draft_layout_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/startup_draft_layout_tests.rs b/codex-rs/tui/src/startup_draft_layout_tests.rs new file mode 100644 index 0000000000..b32d9f1e08 --- /dev/null +++ b/codex-rs/tui/src/startup_draft_layout_tests.rs @@ -0,0 +1,38 @@ +//! Verify provisional composer geometry while startup owns the terminal. + +use super::*; + +#[test] +fn owned_startup_keeps_the_live_bottom_geometry() { + let mut pump = crate::startup_draft::tests::quiet_startup_test_pump(); + pump.bottom_pane.set_status_line_enabled(/*enabled*/ true); + pump.bottom_pane + .set_composer_text("first line\nsecond line".into(), Vec::new(), Vec::new()); + pump.bottom_pane.set_footer_hint_override(Some(vec![ + ("Waiting for startup".into(), String::new()), + ("esc".into(), "cancel".into()), + ])); + let layout = OwnedStartupLayout::new( + &pump.header, + &pump.bottom_pane, + StartupDraftSessionAction::New, + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 16, + ); + let mut buffer = Buffer::empty(area); + layout.render(area, &mut buffer); + let frame = (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!( + "owned_startup_layout", + format!("cursor={:?}\n{frame}", layout.cursor_pos(area)) + .replace(crate::version::CODEX_CLI_VERSION, "") + ); +} diff --git a/codex-rs/tui/src/startup_draft_submission_tests.rs b/codex-rs/tui/src/startup_draft_submission_tests.rs new file mode 100644 index 0000000000..2dedb95d5d --- /dev/null +++ b/codex-rs/tui/src/startup_draft_submission_tests.rs @@ -0,0 +1,160 @@ +//! Verify confirmation follows the visible draft and its selected destination. + +use super::*; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn startup_enter_confirms_one_visible_draft_and_escape_cancels() { + let mut pump = tests::quiet_startup_test_pump(); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + pump.bottom_pane.insert_str("keep this startup draft"); + let draft = pump.bottom_pane.composer_draft_snapshot(); + for _ in 0..3 { + pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter))) + .expect("confirm startup draft"); + } + assert!(pump.submission_pending); + assert_eq!(pump.bottom_pane.composer_draft_snapshot(), draft); + pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Esc))) + .expect("cancel startup submission"); + assert!(!pump.take_submission_intent()); + assert_eq!(pump.bottom_pane.composer_draft_snapshot(), draft); + pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter))) + .expect("confirm again"); + pump.handle_event(&mut tui, TuiEvent::Paste(" with more text".into())) + .expect("edit confirmed startup draft"); + assert!(!pump.take_submission_intent()); + assert_eq!( + pump.bottom_pane.composer_text(), + "keep this startup draft with more text" + ); + let draft = pump.bottom_pane.composer_draft_snapshot(); + pump.session_action = StartupDraftSessionAction::NewFromCommandCenter; + pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter))) + .expect("command-center startup stays edit-only"); + assert!(!pump.take_submission_intent()); + assert_eq!(pump.bottom_pane.composer_draft_snapshot(), draft); + pump.bottom_pane + .set_composer_text(String::new(), Vec::new(), Vec::new()); + pump.events = Box::pin(futures::stream::iter(['c', 'd'].map(|character| { + TuiEvent::Key(KeyEvent::new( + KeyCode::Char(character), + KeyModifiers::CONTROL, + )) + }))); + pump.flush_pending_events(&mut tui) + .await + .expect("command-center startup survives cancellation during the final input drain"); +} + +#[tokio::test] +async fn startup_submission_honors_configured_submit_binding() { + let mut pump = tests::quiet_startup_test_pump(); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + let config = toml::from_str( + "[composer]\nsubmit = \"ctrl-x s\"\n[editor]\ninsert_newline = \"enter\"\nmove_line_start = \"ctrl-x a\"\n" + ) + .unwrap(); + let keymap = crate::keymap::RuntimeKeymap::from_config(&config).unwrap(); + pump.bottom_pane.set_keymap_bindings(&keymap); + pump.key_chords = keymap.chords; + for code in [ + KeyCode::Char('a'), + KeyCode::Char('b'), + KeyCode::Enter, + KeyCode::Char('c'), + ] { + pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(code))) + .expect("rebound Enter inserts one newline after a paste burst"); + } + pump.bottom_pane.flush_composer_paste_burst(); + assert_eq!(pump.bottom_pane.composer_text(), "ab\nc"); + pump.bottom_pane + .set_composer_text("draft".into(), Vec::new(), Vec::new()); + for key in [ + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyEvent::from(KeyCode::Char('a')), + ] { + pump.handle_event(&mut tui, TuiEvent::Key(key)) + .expect("configured editor chord"); + } + assert_eq!(pump.bottom_pane.composer_cursor(), 0); + pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter))) + .expect("Enter does not submit"); + assert!(!pump.submission_pending); + let draft = pump.bottom_pane.composer_draft_snapshot(); + for key in [ + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyEvent::from(KeyCode::Char('s')), + ] { + pump.handle_event(&mut tui, TuiEvent::Key(key)) + .expect("configured submit"); + } + assert!(pump.take_submission_intent()); + assert_eq!(pump.into_draft(), draft); +} + +#[tokio::test] +async fn startup_submission_accepts_enter_after_paste_ambiguity_expires() { + let mut pump = tests::quiet_startup_test_pump(); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + pump.bottom_pane.insert_str("pasted draft"); + pump.pending_paste_newline = Some(( + Instant::now() - STARTUP_PASTE_NEWLINE_TIMEOUT - Duration::from_millis(/*millis*/ 1), + "\n".into(), + )); + pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter))) + .expect("confirm after the ambiguous paste has finished"); + assert!(pump.take_submission_intent()); + assert_eq!(pump.into_draft().text, "pasted draft"); +} + +#[tokio::test] +async fn startup_submission_redirect_to_agents_preserves_only_editable_text() { + for selection in [SessionSelection::AgentsOverview, SessionSelection::Exit] { + let mut pump = tests::quiet_startup_test_pump(); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + pump.update_session_selection(&mut tui, &SessionSelection::StartFresh) + .expect("resolve initial destination"); + pump.bottom_pane + .insert_str("keep this draft in its original context"); + let draft = pump.bottom_pane.composer_draft_snapshot(); + pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter))) + .expect("confirm startup draft"); + pump.update_session_selection(&mut tui, &selection) + .expect("redirect away from startup destination"); + assert!(!pump.take_submission_intent()); + assert_eq!(pump.into_draft(), draft); + } +} + +#[tokio::test] +async fn startup_submission_thread_change_preserves_only_editable_text() { + let mut pump = tests::quiet_startup_test_pump(); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + for destination in [ + codex_protocol::ThreadId::new(), + codex_protocol::ThreadId::new(), + ] { + let selection = SessionSelection::Resume(crate::resume_picker::SessionTarget { + path: None, + thread_id: destination, + cwd: None, + history_mode: None, + }); + pump.update_session_selection(&mut tui, &selection) + .expect("select startup destination"); + assert!(!pump.submission_pending); + if pump.bottom_pane.composer_is_empty() { + pump.bottom_pane.insert_str("retained draft"); + pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter))) + .expect("confirm first destination"); + assert!(pump.submission_pending); + } + } + assert!(!pump.take_submission_intent()); + assert_eq!(pump.into_draft().text, "retained draft"); +} diff --git a/codex-rs/tui/src/startup_draft_tests.rs b/codex-rs/tui/src/startup_draft_tests.rs index 39d38c8bdb..9b8acf2ada 100644 --- a/codex-rs/tui/src/startup_draft_tests.rs +++ b/codex-rs/tui/src/startup_draft_tests.rs @@ -38,7 +38,12 @@ where app_event_rx: rx, initial_screen: StartupDraftInitialScreen::Composer, session_action: StartupDraftSessionAction::New, + resolved_selection: None, + configured_cwd: None, pending_paste_newline: None, + submission_pending: false, + key_chord_matcher: Default::default(), + key_chords: crate::keymap::RuntimeKeymap::defaults().chords, } } @@ -180,7 +185,7 @@ async fn startup_draft_clears_loading_status_when_starting_fresh() { pump.bottom_pane.insert_str("draft while loading"); } let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); - pump.show_initial_screen(&mut tui) + pump.redraw_if_visible(&mut tui) .expect("respect the initial composer or picker screen"); let before = if tui.terminal.viewport_area.is_empty() { "hidden while picker owns input".to_string() @@ -374,6 +379,7 @@ async fn startup_draft_preserves_non_bracketed_multiline_pastes_without_submitti pump.flush_pending_events(&mut tui) .await .expect("preserve multiline non-bracketed paste"); + assert!(!pump.submission_pending); assert_eq!( pump.bottom_pane.composer_text(), @@ -504,8 +510,15 @@ fn startup_draft_allows_local_editor_shortcuts_without_startup_actions() { .expect("use a configured editor movement"); assert_eq!(pump.bottom_pane.composer_cursor(), 0); - for key in [ + handle_startup_draft_key( + &mut pump.bottom_pane, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ) + .expect("honor Enter rebound to local newline editing"); + assert_eq!(pump.bottom_pane.composer_cursor(), 1); + assert_eq!(pump.bottom_pane.composer_text(), "\nfirst "); + + for key in [ KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('\u{16}'), KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), @@ -528,8 +541,8 @@ fn startup_draft_allows_local_editor_shortcuts_without_startup_actions() { ) .expect("ignore configured plain composer actions"); } - assert_eq!(pump.bottom_pane.composer_cursor(), 0); - assert_eq!(pump.bottom_pane.composer_text(), "first "); + assert_eq!(pump.bottom_pane.composer_cursor(), 1); + assert_eq!(pump.bottom_pane.composer_text(), "\nfirst "); assert!(pump.app_event_rx.try_recv().is_err()); } @@ -626,6 +639,16 @@ async fn startup_draft_applies_editor_keymap_without_enabling_vim() { ) .expect("honor a configured safe editor shortcut"); assert_eq!(pump.bottom_pane.composer_cursor(), 0); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter))) + .expect("confirm draft"); + pump.apply_config(&config); + assert!(pump.submission_pending); + config.tui_keymap.composer.submit = + Some(codex_config::types::KeybindingsSpec::Many(Vec::new())); + pump.apply_config(&config); + assert!(!pump.submission_pending); + assert_eq!(pump.bottom_pane.composer_text(), "draftx"); } #[tokio::test] @@ -634,7 +657,7 @@ async fn startup_draft_waits_for_onboarding_before_accepting_input() { let mut composer_tui = crate::tui::test_support::make_test_tui().expect("create composer test terminal"); composer_pump - .show_initial_screen(&mut composer_tui) + .redraw_if_visible(&mut composer_tui) .expect("draw the composer when no protected screen is expected"); assert!(!composer_tui.terminal.viewport_area.is_empty()); drop(composer_tui); @@ -669,7 +692,7 @@ async fn startup_draft_waits_for_onboarding_before_accepting_input() { StartupDraftInitialScreen::Composer }; let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); - pump.show_initial_screen(&mut tui) + pump.redraw_if_visible(&mut tui) .expect("keep the composer hidden until onboarding finishes"); pump.flush_pending_events(&mut tui) diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index e88f02808b..84e1330a02 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -208,7 +208,79 @@ pub(super) async fn run_main_inner( } else { startup_draft::StartupDraftSessionAction::New }; - let mut startup_draft = startup_draft::StartupDraft::new(initial_screen, session_action)?; + // Local-daemon discovery does not change client config precedence. Resolve explicit + // remote selection and the environment without opening a server connection. + let presentation_target = app_server_target_for_launch( + explicit_remote_endpoint.clone(), + /*default_daemon_socket*/ None, + reuse_implicit_local_daemon, + workload_identity_selected, + std::env::var_os(codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR).as_deref(), + )?; + let prepared_environment_manager = + if should_load_configured_environments(&launch_loader_overrides, &presentation_target) { + EnvironmentManager::prepare_from_codex_home(&codex_home).await + } else { + EnvironmentManager::prepare_from_env().await + } + .map_err(std::io::Error::other)?; + if cli.shared.worktree + && (presentation_target.uses_remote_workspace() + || prepared_environment_manager.default_environment_is_remote()) + { + return Err(std::io::Error::other( + "`--worktree` is only supported for local sessions", + )); + } + let cwd = cli.cwd.clone(); + let config_cwd = config_cwd_for_app_server_target( + cwd.as_deref(), + &presentation_target, + prepared_environment_manager.default_environment_is_remote(), + )?; + // Reuse the profile path resolved above; do not reconstruct profile precedence here. + let mut loader_overrides = launch_loader_overrides; + loader_overrides.ignore_login_requirements = presentation_target.uses_remote_workspace(); + let presentation = startup_presentation::load( + &cli, + &codex_home, + loader_overrides.clone(), + cli_kv_overrides.clone(), + config_cwd, + ) + .await + .map_err(|err| { + if let Some(config_error) = err + .get_ref() + .and_then(|inner| inner.downcast_ref::()) + { + std::io::Error::other(format!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error.config_error()) + )) + } else { + err + } + })?; + // Keep normal terminal signals available until local configuration is ready. Once raw + // mode begins, StartupDraft immediately takes ownership of input. + let (initialized_terminal, terminal_restore_guard) = tokio::task::spawn_blocking(|| { + tui::init().map(|terminal| (terminal, TerminalRestoreGuard::new())) + }) + .await + .map_err(std::io::Error::other)??; + let startup_presentation::StartupPresentation { + bootstrap_config, + config_cwd, + screen, + } = presentation; + let mut startup_draft = startup_draft::StartupDraft::new( + initialized_terminal, + terminal_restore_guard, + initial_screen, + session_action, + screen, + )?; let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon { startup_draft @@ -233,49 +305,8 @@ pub(super) async fn run_main_inner( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), )?; - let prepared_environment_manager = - if should_load_configured_environments(&loader_overrides, &app_server_target) { - startup_draft - .run_until(EnvironmentManager::prepare_from_codex_home(&codex_home)) - .await? - } else { - startup_draft - .run_until(EnvironmentManager::prepare_from_env()) - .await? - } - .map_err(std::io::Error::other)?; - if cli.shared.worktree - && (app_server_target.uses_remote_workspace() - || prepared_environment_manager.default_environment_is_remote()) - { - return Err(std::io::Error::other( - "`--worktree` is only supported for local sessions", - )); - } - let cwd = cli.cwd.clone(); - let config_cwd = config_cwd_for_app_server_target( - cwd.as_deref(), - &app_server_target, - prepared_environment_manager.default_environment_is_remote(), - )?; - let mut loader_overrides = loader_overrides; - if let Some(profile_v2) = cli.config_profile_v2.as_ref() { - let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2); - loader_overrides.user_config_path = Some(user_config_path); - loader_overrides.user_config_profile = Some(profile_v2.clone()); - } - loader_overrides.ignore_login_requirements = app_server_target.uses_remote_workspace(); - - let bootstrap_config = startup_draft - .run_until(load_bootstrap_config_or_exit( - &codex_home, - config_cwd.as_ref(), - cli_kv_overrides.clone(), - loader_overrides.clone(), - strict_config, - CloudConfigBundleLoader::default(), - )) - .await?; + // The pre-paint bootstrap used these same local/remote config inputs. Reuse it here; + // implicit daemon discovery above changes transport, not the client configuration cwd. let screen_reader_result = if !loader_overrides.ignore_user_config { startup_draft .run_until(screen_reader::initialize( diff --git a/codex-rs/tui/src/startup_presentation.rs b/codex-rs/tui/src/startup_presentation.rs new file mode 100644 index 0000000000..5ce74e4634 --- /dev/null +++ b/codex-rs/tui/src/startup_presentation.rs @@ -0,0 +1,115 @@ +//! Resolve first-frame screen ownership from the existing client configuration loader. +//! +//! The caller supplies its resolved profile overrides and configuration cwd, so this path does +//! not introduce separate profile or project precedence. Loading excludes cloud fetches and +//! returns the bootstrap result for reuse by startup. Final cloud or selected-project settings +//! may update screen policy before handing the terminal to the conversation. + +use std::io; +use std::path::Path; + +use codex_config::CloudConfigBundleLoader; +use codex_config::ConfigLoadOptions; +use codex_config::LoaderOverrides; +use codex_config::TomlValue; +use codex_features::Feature; +use codex_features::FeatureConfigSource; +use codex_features::FeatureOverrides; +use codex_features::Features; +use codex_utils_absolute_path::AbsolutePathBuf; + +use crate::Cli; +use crate::keymap::RuntimeKeymap; +use crate::legacy_core::config::ConfigTomlLoadResult; +use crate::legacy_core::config::ManagedFeatures; +use crate::legacy_core::config::load_config_toml_with_layer_stack; +use crate::startup_draft::StartupScreen; + +/// Screen policy and the already-loaded bootstrap configuration for this exact loading context. +pub(super) struct StartupPresentation { + pub(super) bootstrap_config: ConfigTomlLoadResult, + pub(super) config_cwd: Option, + pub(super) screen: StartupScreen, +} + +/// Load client presentation settings without session setup or remote app-server connections. +/// +/// `loader_overrides` must already contain the selected profile and remote login-policy choice. +/// `config_cwd` must come from the existing target/environment-aware cwd resolver. Reuse the +/// returned bootstrap only when these loading inputs still match the later startup context. +pub(super) async fn load( + cli: &Cli, + codex_home: &Path, + loader_overrides: LoaderOverrides, + cli_kv_overrides: Vec<(String, TomlValue)>, + config_cwd: Option, +) -> io::Result { + let bootstrap_config = load_config_toml_with_layer_stack( + codex_home, + config_cwd.as_ref(), + cli_kv_overrides, + ConfigLoadOptions { + loader_overrides, + strict_config: cli.strict_config, + cloud_config_bundle: CloudConfigBundleLoader::default(), + }, + ) + .await?; + let config_toml = &bootstrap_config.config_toml; + let configured_features = Features::from_sources( + FeatureConfigSource { + features: config_toml.features.as_ref(), + experimental_use_unified_exec_tool: config_toml.experimental_use_unified_exec_tool, + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + // Full configuration construction will publish its normal managed-feature warnings once. + let mut startup_warnings = Vec::new(); + let features = ManagedFeatures::from_configured_with_warnings( + configured_features, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .clone(), + &mut startup_warnings, + )?; + let alternate_screen = config_toml + .tui + .as_ref() + .map(|tui| tui.alternate_screen) + .unwrap_or_default(); + let use_alt_screen = crate::determine_alt_screen_mode(cli.no_alt_screen, alternate_screen); + let transcript_mode = crate::transcript_mode::TranscriptMode::resolve( + features.enabled(Feature::TranscriptV2), + use_alt_screen, + ); + let status_line_enabled = config_toml + .tui + .as_ref() + .and_then(|tui| tui.status_line.as_ref()) + .is_none_or(|items| !items.is_empty()); + let default_tui_settings = codex_config::types::Tui::default(); + let tui_settings = config_toml.tui.as_ref().unwrap_or(&default_tui_settings); + let keymap = RuntimeKeymap::from_config(&tui_settings.keymap).map_err(io::Error::other)?; + let disable_paste_burst = tui_settings + .disable_paste_burst + .or(config_toml.disable_paste_burst) + .unwrap_or(/*default*/ false); + Ok(StartupPresentation { + bootstrap_config, + config_cwd, + screen: StartupScreen { + use_alt_screen, + transcript_mode, + status_line_enabled, + keymap, + disable_paste_burst, + }, + }) +} + +#[cfg(test)] +#[path = "startup_presentation_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/startup_presentation_tests.rs b/codex-rs/tui/src/startup_presentation_tests.rs new file mode 100644 index 0000000000..5cf50dcaba --- /dev/null +++ b/codex-rs/tui/src/startup_presentation_tests.rs @@ -0,0 +1,97 @@ +//! Check first-frame editor and screen settings using the real client configuration loader. + +use super::*; +use clap::Parser; +use crossterm::event::KeyCode; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn selected_profile_controls_submit_before_the_first_frame() -> anyhow::Result<()> { + let home = tempfile::tempdir()?; + std::fs::write( + home.path().join("config.toml"), + "disable_paste_burst = false\n", + )?; + let selected = AbsolutePathBuf::from_absolute_path(home.path().join("work.config.toml"))?; + let cli = Cli::try_parse_from(["codex"])?; + for (submit, expected_bindings) in [ + ("[]", Vec::new()), + ("\"f12\"", vec![crate::key_hint::plain(KeyCode::F(12))]), + ] { + std::fs::write( + &selected, + format!("disable_paste_burst = true\n[tui.keymap.composer]\nsubmit = {submit}\n"), + )?; + let presentation = load( + &cli, + home.path(), + LoaderOverrides { + user_config_path: Some(selected.clone()), + user_config_profile: Some("work".parse()?), + ignore_project_config: true, + ..LoaderOverrides::without_managed_config_for_tests() + }, + Vec::new(), + /*config_cwd*/ None, + ) + .await?; + assert_eq!( + ( + presentation.screen.keymap.composer.submit, + presentation.screen.disable_paste_burst, + ), + (expected_bindings, true), + ); + } + Ok(()) +} + +#[tokio::test] +async fn first_frame_respects_screen_and_status_line_overrides() -> anyhow::Result<()> { + let home = tempfile::tempdir()?; + for (config, args, expected) in [ + ( + "[features]\ntranscript_v2 = true\n", + vec!["codex"], + (true, true, true), + ), + ( + "[features]\ntranscript_v2 = true\n", + vec!["codex", "--no-alt-screen"], + (false, false, true), + ), + ( + "[features]\ntranscript_v2 = false\n", + vec!["codex"], + (true, false, true), + ), + ( + "[tui]\nalternate_screen = \"never\"\nstatus_line = []\n", + vec!["codex"], + (false, false, false), + ), + ] { + std::fs::write(home.path().join("config.toml"), config)?; + let cli = Cli::try_parse_from(args)?; + let presentation = load( + &cli, + home.path(), + LoaderOverrides { + ignore_project_config: true, + ..LoaderOverrides::without_managed_config_for_tests() + }, + Vec::new(), + /*config_cwd*/ None, + ) + .await?; + assert_eq!( + ( + presentation.screen.use_alt_screen, + presentation.screen.transcript_mode.is_owned(), + presentation.screen.status_line_enabled + ), + expected, + ); + } + Ok(()) +} diff --git a/codex-rs/tui/src/startup_recovery.rs b/codex-rs/tui/src/startup_recovery.rs new file mode 100644 index 0000000000..b12b8312f2 --- /dev/null +++ b/codex-rs/tui/src/startup_recovery.rs @@ -0,0 +1,179 @@ +//! Retain one startup draft in memory until the intended session accepts the handoff. +//! +//! Recovery belongs to the startup task, never a process-global file, log, or session. After a +//! startup error the caller restores the terminal before showing the unsent text. Printing takes +//! the snapshot, so nested fatal/error paths cannot print it twice. + +use std::cell::RefCell; +use std::future::Future; +use std::io::Write; + +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::ComposerDraftSnapshot; +use crate::bottom_pane::InputResult; + +tokio::task_local! { + static DRAFT: RefCell>; +} + +struct RecoveryDraft { + snapshot: ComposerDraftSnapshot, + stage: RecoveryStage, +} + +enum RecoveryStage { + Startup, + HandedOff, + Submitted, + Dispatched(String), +} + +/// Keep draft recovery isolated to this startup, including futures polled during initialization. +pub(crate) async fn scope(future: F) -> F::Output { + DRAFT.scope(RefCell::new(/*value*/ None), future).await +} + +/// Replace the single recovery copy after input changes or before handing the draft off. +pub(crate) fn remember(snapshot: ComposerDraftSnapshot) { + let _ = DRAFT.try_with(|draft| { + *draft.borrow_mut() = Some(RecoveryDraft { + snapshot, + stage: RecoveryStage::Startup, + }); + }); +} + +/// Track the merged destination only once startup has transferred its draft into ChatWidget. +pub(crate) fn handed_off(snapshot: impl FnOnce() -> ComposerDraftSnapshot) { + let _ = DRAFT.try_with(|draft| { + if let Some(draft) = draft.borrow_mut().as_mut() { + draft.snapshot = snapshot(); + draft.stage = RecoveryStage::HandedOff; + } + }); +} + +/// Refresh pending recovery after local edits without retaining any post-startup input. +pub(crate) fn refresh_after_handoff(snapshot: impl FnOnce() -> ComposerDraftSnapshot) { + let _ = DRAFT.try_with(|draft| { + if let Some(draft) = draft.borrow_mut().as_mut() + && matches!(draft.stage, RecoveryStage::HandedOff) + { + draft.snapshot = snapshot(); + } + }); +} + +/// Retain actual submission output, including burst text flushed while consuming the composer. +pub(crate) fn submitted(result: &InputResult) { + let submission = match result { + InputResult::Submitted { + text, + text_elements, + } => Some((text, text_elements, &[][..])), + InputResult::Queued { + text, + text_elements, + pending_pastes, + .. + } => Some((text, text_elements, pending_pastes.as_slice())), + InputResult::CommandWithArgs(crate::slash_command::SlashCommand::Plan, ..) => None, + InputResult::None + | InputResult::ParentOwnedInputBlocked + | InputResult::Command(_) + | InputResult::ServiceTierCommand(_) + | InputResult::CommandWithArgs(..) => return, + }; + let _ = DRAFT.try_with(|draft| { + if let Some(draft) = draft.borrow_mut().as_mut() + && matches!(draft.stage, RecoveryStage::HandedOff) + { + if let Some((text, text_elements, pending_pastes)) = submission { + draft.snapshot.text.clone_from(text); + draft.snapshot.text_elements.clone_from(text_elements); + draft.snapshot.pending_pastes = pending_pastes.to_vec(); + draft.snapshot.cursor = text.len(); + } + // Later editor input is a different draft; preserve this one until acknowledgment. + draft.stage = RecoveryStage::Submitted; + } + }); +} + +/// Associate the recovered submission with its actual queued request, not an earlier CLI prompt. +pub(crate) fn bind_submission(text: &str, client_id: &str) { + let _ = DRAFT.try_with(|draft| { + if let Some(draft) = draft.borrow_mut().as_mut() + && matches!(draft.stage, RecoveryStage::Submitted) + { + let expanded = ChatComposer::expand_pending_pastes( + &draft.snapshot.text, + draft.snapshot.text_elements.clone(), + &draft.snapshot.pending_pastes, + ) + .0; + // /plan submits its arguments while recovery retains the original command. + if expanded == text + || crate::bottom_pane::prompt_args::parse_slash_name(&expanded) + .is_some_and(|(name, args, _)| name == "plan" && args.trim() == text) + { + draft.stage = RecoveryStage::Dispatched(client_id.to_owned()); + } + } + }); +} + +/// Only acceptance of the recovered message releases its unsent copy. +pub(crate) fn acknowledged(client_id: &str) { + let _ = DRAFT.try_with(|draft| { + let mut draft = draft.borrow_mut(); + if draft.as_ref().is_some_and( + |draft| matches!(&draft.stage, RecoveryStage::Dispatched(id) if id == client_id), + ) { + draft.take(); + } + }); +} + +/// Forget recovery once startup safely transfers the draft or the user cancels the session. +pub(crate) fn clear() { + let _ = DRAFT.try_with(|draft| { + draft.borrow_mut().take(); + }); +} + +fn take_unsent_text() -> Option { + let snapshot = DRAFT + .try_with(|draft| draft.borrow_mut().take()) + .ok()?? + .snapshot; + let (text, _) = ChatComposer::expand_pending_pastes( + &snapshot.text, + snapshot.text_elements, + &snapshot.pending_pastes, + ); + if text.is_empty() { + return None; + } + // Keep the typed content recoverable without letting pasted control bytes change the terminal. + let mut visible = String::with_capacity(text.len()); + for character in text.chars() { + if character.is_control() && !matches!(character, '\n' | '\t') { + visible.extend(character.escape_default()); + } else { + visible.push(character); + } + } + Some(visible) +} + +/// Display unsent text only after the caller has restored normal terminal modes. +pub(crate) fn print_unsent_draft() { + if let Some(text) = take_unsent_text() { + let _ = writeln!(std::io::stderr().lock(), "\nUnsent draft:\n{text}\n"); + } +} + +#[cfg(test)] +#[path = "startup_recovery_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/startup_recovery_tests.rs b/codex-rs/tui/src/startup_recovery_tests.rs new file mode 100644 index 0000000000..a0afc87d0e --- /dev/null +++ b/codex-rs/tui/src/startup_recovery_tests.rs @@ -0,0 +1,168 @@ +//! Verify unsent startup text survives edits, buffered pastes, and handoff. + +use super::*; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use pretty_assertions::assert_eq; + +fn snapshot(text: &str) -> ComposerDraftSnapshot { + ComposerDraftSnapshot { + text: text.to_string(), + cursor: text.len(), + text_elements: Vec::new(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: Vec::new(), + pending_pastes: Vec::new(), + startup_local_history: Vec::new(), + last_composer_activity_at: None, + sparkle_draft: Default::default(), + } +} + +#[tokio::test] +async fn recovery_keeps_edits_and_pastes_after_chatwidget_handoff() { + for destination in ["", "existing draft\n"] { + scope(async { + let (mut chat, _sender, _rx, _op_rx) = + crate::chatwidget::tests::make_chatwidget_manual_with_sender().await; + let draft = snapshot("startup draft"); + remember(draft.clone()); + chat.handle_paste(destination.trim_end().to_string()); + let mut pending = Some(draft); + let mut confirmed = true; + chat.restore_startup_input_when_ready(&mut pending, &mut confirmed); + assert!(pending.is_none()); + chat.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + let pasted = format!(" edited {}", "x".repeat(/*n*/ 1100)); + chat.handle_paste(pasted.clone()); + // This is the exact expanded text printed if startup fails before session readiness. + assert_eq!( + take_unsent_text(), + Some(format!("{destination}startup draf{pasted}")), + ); + }) + .await; + } +} + +#[tokio::test] +async fn recovery_refresh_stops_after_submission_or_startup_completion() { + scope(async { + remember(snapshot("before handoff")); + refresh_after_handoff(|| panic!("protected input must not replace the startup draft")); + handed_off(|| snapshot("confirmed submission")); + bind_submission("confirmed submission", "initial-prompt"); + acknowledged("initial-prompt"); + submitted(&InputResult::Submitted { + text: "confirmed submission".to_string(), + text_elements: Vec::new(), + }); + bind_submission("other message", "unrelated"); + bind_submission("confirmed submission", "recovered"); + bind_submission("confirmed submission", "later-identical-message"); + acknowledged("initial-prompt"); + acknowledged("later-identical-message"); + refresh_after_handoff(|| panic!("new edits must not replace a submitted draft")); + assert_eq!(take_unsent_text(), Some("confirmed submission".to_string())); + + remember(snapshot("editable draft")); + handed_off(|| snapshot("merged draft")); + submitted(&InputResult::Submitted { + text: "merged draft".into(), + text_elements: Vec::new(), + }); + bind_submission("merged draft", "accepted"); + acknowledged("accepted"); + assert_eq!(take_unsent_text(), None); + for submission in [ + InputResult::CommandWithArgs( + crate::slash_command::SlashCommand::Plan, + "make a plan".into(), + Vec::new(), + ), + InputResult::Queued { + text: "/plan make a plan".into(), + text_elements: Vec::new(), + action: crate::bottom_pane::QueuedInputAction::ParseSlash, + pending_pastes: Vec::new(), + }, + ] { + remember(snapshot("/plan make a plan")); + handed_off(|| snapshot("/plan make a plan")); + submitted(&submission); + bind_submission("make a plan", "plan-request"); + acknowledged("plan-request"); + assert_eq!(take_unsent_text(), None); + } + handed_off(|| panic!("completed startup must not retain input")); + refresh_after_handoff(|| panic!("completed startup must not copy edits")); + assert_eq!(take_unsent_text(), None); + }) + .await; +} + +#[tokio::test] +async fn recovery_keeps_buffered_input_on_exit_or_queued_submission() { + for queue in [false, true] { + scope(async { + let (mut chat, _sender, _rx, _op_rx) = + crate::chatwidget::tests::make_chatwidget_manual_with_sender().await; + let draft = snapshot("startup draft"); + remember(draft.clone()); + let mut pending = Some(draft); + let mut confirmed = false; + chat.restore_startup_input_when_ready(&mut pending, &mut confirmed); + chat.set_queue_submissions_until_session_configured(queue); + // Rapid raw keys remain buffered outside the textarea until the burst ends. + chat.handle_key_event(KeyEvent::from(KeyCode::Char('x'))); + chat.handle_key_event(KeyEvent::from(KeyCode::Char('y'))); + assert_eq!(chat.composer_text_with_pending(), "startup draft"); + if !queue { + // A fatal exit must include keys still waiting for the paste timer. + assert_eq!(take_unsent_text(), Some("startup draftxy".to_string())); + return; + } + // Queueing must capture the buffer without waiting for a draw/tick flush. + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!( + chat.queued_user_message_texts(), + vec!["startup draftxy".to_string()] + ); + assert_eq!(take_unsent_text(), Some("startup draftxy".to_string())); + }) + .await; + } +} + +#[tokio::test] +async fn recovery_uses_pending_pastes_from_the_actual_queued_result() { + scope(async { + remember(snapshot("before burst")); + handed_off(|| snapshot("before burst")); + let placeholder = "[Pasted text]"; + submitted(&InputResult::Queued { + text: placeholder.to_string(), + text_elements: vec![TextElement::new( + ByteRange { + start: 0, + end: placeholder.len(), + }, + Some(placeholder.to_string()), + )], + action: crate::bottom_pane::QueuedInputAction::Literal, + pending_pastes: vec![( + placeholder.to_string(), + "complete queued paste\n\twith control\u{1b}[31m".to_string(), + )], + }); + assert_eq!( + take_unsent_text(), + Some("complete queued paste\n\twith control\\u{1b}[31m".to_string()) + ); + assert_eq!(take_unsent_text(), None); + }) + .await; +} diff --git a/codex-rs/tui/src/tui_startup_tests.rs b/codex-rs/tui/src/tui_startup_tests.rs index 19e1d337c8..a335ca9785 100644 --- a/codex-rs/tui/src/tui_startup_tests.rs +++ b/codex-rs/tui/src/tui_startup_tests.rs @@ -200,9 +200,20 @@ async fn startup_typeahead_pty_child() { return; }; let signals = PathBuf::from(signals); + let initialized_terminal = crate::tui::init().expect("initialize startup terminal"); + let terminal_restore_guard = crate::TerminalRestoreGuard::new(); let mut startup_draft = crate::startup_draft::StartupDraft::new( + initialized_terminal, + terminal_restore_guard, crate::startup_draft::StartupDraftInitialScreen::Composer, crate::startup_draft::StartupDraftSessionAction::New, + crate::startup_draft::StartupScreen { + use_alt_screen: false, + transcript_mode: crate::transcript_mode::TranscriptMode::Terminal, + status_line_enabled: true, + keymap: crate::keymap::RuntimeKeymap::defaults(), + disable_paste_burst: false, + }, ) .expect("initialize the real startup composer"); diff --git a/codex-rs/tui/tests/suite/reconnect.rs b/codex-rs/tui/tests/suite/reconnect.rs index f9a279a5f0..b5ab0707dd 100644 --- a/codex-rs/tui/tests/suite/reconnect.rs +++ b/codex-rs/tui/tests/suite/reconnect.rs @@ -20,16 +20,22 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re // macOS's default temporary directory leaves too little room for the control socket path. let codex_home = tempfile::tempdir_in("/tmp")?; write_test_config(codex_home.path(), &repo_root)?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!("{config}\n[tui]\nstatus_line = [\"thread-id\"]\n"), + )?; let socket = codex_app_server_client::app_server_control_socket_path(codex_home.path())?; std::fs::create_dir_all(socket.parent().unwrap())?; let listener = UnixListener::bind(socket.as_path())?; let (disconnect_tx, mut disconnect_rx) = tokio::sync::oneshot::channel(); let (restore_tx, restore_rx) = tokio::sync::oneshot::channel(); let server_cwd = repo_root.clone(); + let id = "00000000-0000-0000-0000-000000000001"; let server = tokio::spawn(async move { let mut methods = Vec::new(); let mut restore_rx = Some(restore_rx); - let id = "00000000-0000-0000-0000-000000000001"; let thread = json!({ "id": id, "sessionId": id, "preview": "", "ephemeral": false, "modelProvider": "openai", "createdAt": 1, "updatedAt": 2, @@ -91,7 +97,8 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re } "model/list" => json!({"data": [], "nextCursor": null}), "config/read" => { - json!({"config": {"model": "gpt-5.6-terra", "model_provider": "openai", "projects": { + json!({"config": {"model": "gpt-5.6-terra", "model_provider": "openai", + "tui": {"status_line": ["thread-id"]}, "projects": { server_cwd.to_string_lossy(): {"trust_level": "trusted"} }}, "origins": {}, "layers": []}) } @@ -149,7 +156,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re let mut disconnect_tx = Some(disconnect_tx); let mut restore_tx = Some(restore_tx); for expected in [ - "gpt-5.6-terra", + id, // The model label does not establish that the client has attached a thread. "preserved-draft", "Reconnecting", "preserved-draft!", @@ -165,7 +172,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re terminal.screen_contents() ); match expected { - "gpt-5.6-terra" => terminal.write_input(b"preserved-draft")?, + ready if ready == id => terminal.write_input(b"preserved-draft")?, "preserved-draft" => { disconnect_tx.take().unwrap().send(()).unwrap(); }