diff --git a/codex-rs/cli/tests/features.rs b/codex-rs/cli/tests/features.rs index 59a290611f..d84dd206f7 100644 --- a/codex-rs/cli/tests/features.rs +++ b/codex-rs/cli/tests/features.rs @@ -38,6 +38,82 @@ fn strict_config_rejects_unknown_config_override() -> Result<()> { Ok(()) } +#[test] +fn interactive_validates_config_before_requiring_terminal() -> Result<()> { + let cases: &[(&[&str], &str, &str, &str)] = &[ + (&[], "config.toml", "model = [", "Error loading config.toml"), + ( + &["--strict-config"], + "config.toml", + "unknown_key = true", + "unknown configuration field", + ), + ( + &["--strict-config", "-c", "foo=bar"], + "config.toml", + "", + "unknown configuration field", + ), + ( + &["--profile", "work"], + "work.config.toml", + "model = [", + "work.config.toml", + ), + ( + &["-c", "model_provider=\"missing\""], + "config.toml", + "", + "Model provider `missing` not found", + ), + (&[], "config.toml", "", "stdin is not a terminal"), + ]; + + for &(args, config_file, contents, expected_error) in cases { + let codex_home = TempDir::new()?; + std::fs::write(codex_home.path().join(config_file), contents)?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.env("TERM", "xterm-256color") + .current_dir(codex_home.path()) + .args(args) + .assert() + .failure() + .stderr(contains(expected_error)); + } + + Ok(()) +} + +#[test] +fn interactive_remote_default_preserves_remote_working_directory_before_requiring_terminal() +-> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write(codex_home.path().join("config.toml"), "")?; + std::fs::write( + codex_home.path().join("environments.toml"), + r#"default = "remote" +include_local = false + +[[environments]] +id = "remote" +url = "ws://127.0.0.1:4512" +"#, + )?; + let remote_only_cwd = codex_home.path().join("remote-only-working-directory"); + + let mut cmd = codex_command(codex_home.path())?; + cmd.env("TERM", "xterm-256color") + .current_dir(codex_home.path()) + .arg("--cd") + .arg(remote_only_cwd) + .assert() + .failure() + .stderr(contains("stdin is not a terminal")); + + Ok(()) +} + #[test] fn strict_config_is_not_supported_for_cloud_command() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 012b4a6eb0..c6169144ab 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -73,6 +73,7 @@ use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; use crate::resume_picker::SessionTarget; use crate::session_state::ThreadSessionState; +use crate::startup_draft::StartupDraftPump; #[cfg(test)] use crate::test_support::PathBufExt; #[cfg(test)] @@ -225,6 +226,7 @@ mod resize_reflow; mod safety_buffering; mod session_lifecycle; mod side; +mod startup; mod startup_prompts; mod thread_events; mod thread_goal_actions; @@ -595,6 +597,10 @@ pub(crate) struct App { pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, pending_startup_thread_start: bool, + /// Keeps protected screens quarantined until initialized chat receives genuine user input. + startup_protected_input_boundary: bool, + /// Keeps that boundary armed while a startup approval waits for the typing-idle timer. + startup_pending_protected_request: bool, /// Invalidates in-flight full rate-limit reads when a newer rolling hard stop arrives. rate_limit_hard_stop_generation: u64, // Serialize plugin enablement writes per plugin so stale completions cannot @@ -635,38 +641,6 @@ fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option Option { - let provider = create_model_provider(provider.clone(), /*auth_manager*/ None); - match provider.runtime_base_url().await { - Ok(base_url) => base_url, - Err(err) => { - tracing::warn!(%err, "failed to resolve runtime model provider base URL for status"); - None - } - } -} - -fn spawn_startup_thread_start( - app_server: &AppServerSession, - config: Config, - app_event_tx: AppEventSender, -) { - let request_handle = app_server.request_handle(); - let thread_params_mode = app_server.thread_params_mode(); - let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf); - tokio::spawn(async move { - let result = crate::app_server_session::start_thread_with_request_handle( - request_handle, - config, - thread_params_mode, - remote_cwd_override, - ) - .await - .map_err(|err| format!("{err:#}")); - app_event_tx.send(AppEvent::StartupThreadStarted { result }); - }); -} - #[derive(Debug, Clone, PartialEq, Eq)] enum ActiveTurnSteerRace { Missing, @@ -775,548 +749,6 @@ impl App { } } - #[allow(clippy::too_many_arguments)] - pub async fn run( - tui: &mut tui::Tui, - mut app_server: AppServerSession, - mut config: Config, - launch_cwd: PathBuf, - cli_kv_overrides: Vec<(String, TomlValue)>, - harness_overrides: ConfigOverrides, - loader_overrides: LoaderOverrides, - cloud_config_bundle: CloudConfigBundleLoader, - initial_prompt: Option, - initial_images: Vec, - session_selection: SessionSelection, - feedback: codex_feedback::CodexFeedback, - is_first_run: bool, - should_prompt_windows_sandbox_nux_at_startup: bool, - app_server_target: AppServerTarget, - state_db: Option, - environment_manager: Arc, - startup_elapsed_before_app: Duration, - startup_bootstrap: Option, - startup_hooks_browser: Option, - ) -> Result { - use tokio_stream::StreamExt; - let startup_started_at = Instant::now(); - let (app_event_tx, mut app_event_rx) = unbounded_channel(); - let app_event_tx = AppEventSender::new(app_event_tx); - emit_project_config_warnings(&app_event_tx, &config); - emit_system_bwrap_warning(&app_event_tx, &config); - tui.set_notification_settings( - config.tui_notifications.method, - config.tui_notifications.condition, - ); - - let harness_overrides = - normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; - let bootstrap = match startup_bootstrap { - Some(bootstrap) => bootstrap, - None => app_server.bootstrap(&config).await?, - }; - let bootstrap_ms = bootstrap.duration.as_millis(); - if matches!( - &session_selection, - SessionSelection::StartFresh | SessionSelection::Exit - ) { - apply_managed_new_thread_defaults( - &mut config, - app_server.managed_new_thread_defaults(), - &cli_kv_overrides, - &harness_overrides, - ); - } - let mut model = config.model.clone().unwrap_or(bootstrap.default_model); - let available_models = bootstrap.available_models; - let remote_connection = crate::status::remote_connection::remote_connection_status_value( - &app_server_target, - app_server.server_version(), - ); - let exit_info = handle_model_migration_prompt_if_needed( - tui, - &mut config, - model.as_str(), - &app_event_tx, - &available_models, - ) - .await?; - if let Some(exit_info) = exit_info { - app_server - .shutdown() - .await - .inspect_err(|err| { - tracing::warn!("app-server shutdown failed: {err}"); - }) - .ok(); - return Ok(exit_info); - } - if let Some(updated_model) = config.model.clone() { - model = updated_model; - } - let model_catalog = Arc::new(ModelCatalog::new(available_models.clone())); - let feedback_audience = bootstrap.feedback_audience; - let auth_mode = bootstrap.auth_mode; - let has_chatgpt_account = bootstrap.has_chatgpt_account; - let has_codex_backend_auth = matches!(auth_mode, Some(TelemetryAuthMode::Chatgpt)); - let requires_openai_auth = bootstrap.requires_openai_auth; - let status_account_display = bootstrap.status_account_display.clone(); - let initial_plan_type = bootstrap.plan_type; - let session_telemetry = SessionTelemetry::new( - ThreadId::new(), - model.as_str(), - model.as_str(), - /*account_id*/ None, - bootstrap.account_email.clone(), - auth_mode, - codex_login::default_client::originator().value, - config.otel.log_user_prompt, - user_agent(), - serde_json::from_value(serde_json::json!("cli")) - .unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")), - ); - if config - .tui_status_line - .as_ref() - .is_some_and(|cmd| !cmd.is_empty()) - { - session_telemetry.counter("codex.status_line", /*inc*/ 1, &[]); - } - - let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); - let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false)); - let workspace_command_runner: WorkspaceCommandRunner = Arc::new( - AppServerWorkspaceCommandRunner::new(app_server.request_handle()), - ); - let runtime_model_provider_started_at = Instant::now(); - let runtime_model_provider_base_url = - resolve_runtime_model_provider_base_url(&config.model_provider).await; - let runtime_model_provider_ms = runtime_model_provider_started_at.elapsed().as_millis(); - - let enhanced_keys_supported = tui.enhanced_keys_supported(); - let wait_for_initial_session_configured = - Self::should_wait_for_initial_session(&session_selection); - let should_prompt_for_paused_goal_after_startup_resume = - Self::should_prompt_for_paused_goal_after_startup_resume( - &session_selection, - &initial_prompt, - &initial_images, - ); - let thread_and_widget_started_at = Instant::now(); - let pending_startup_thread_start = matches!( - &session_selection, - SessionSelection::StartFresh | SessionSelection::Exit - ); - let (mut chat_widget, initial_started_thread) = match session_selection { - SessionSelection::StartFresh | SessionSelection::Exit => { - spawn_startup_thread_start(&app_server, config.clone(), app_event_tx.clone()); - // Count a startup tooltip once the initial chat widget can render it. - let startup_tooltip_override = - prepare_startup_tooltip_override(&mut config, &available_models, is_first_run) - .await; - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.clone(), - workspace_command_runner: Some(workspace_command_runner.clone()), - initial_user_message: crate::chatwidget::create_initial_user_message( - initial_prompt.clone(), - initial_images.clone(), - // CLI prompt args are plain strings, so they don't provide element ranges. - Vec::new(), - ), - enhanced_keys_supported, - has_chatgpt_account, - has_codex_backend_auth, - model_catalog: model_catalog.clone(), - feedback: feedback.clone(), - is_first_run, - status_account_display: status_account_display.clone(), - runtime_model_provider_base_url: runtime_model_provider_base_url.clone(), - initial_plan_type, - model: Some(model.clone()), - startup_tooltip_override, - status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), - terminal_title_invalid_items_warned: terminal_title_invalid_items_warned - .clone(), - session_telemetry: session_telemetry.clone(), - }; - let mut chat_widget = ChatWidget::new_with_app_event(init); - chat_widget.set_queue_submissions_until_session_configured(/*queue*/ true); - (chat_widget, None) - } - SessionSelection::Resume(target_session) => { - if let Some(history_mode) = target_session.history_mode { - app_server.remember_thread_history_mode(target_session.thread_id, history_mode); - } - let model_settings = config_persistence::resume_model_settings_for_overrides( - &config, - &harness_overrides, - ); - let resumed = app_server - .resume_thread(config.clone(), target_session.thread_id, model_settings) - .await - .map_err(|err| session_start_error("resume", &target_session, err))?; - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.clone(), - workspace_command_runner: Some(workspace_command_runner.clone()), - initial_user_message: crate::chatwidget::create_initial_user_message( - initial_prompt.clone(), - initial_images.clone(), - // CLI prompt args are plain strings, so they don't provide element ranges. - Vec::new(), - ), - enhanced_keys_supported, - has_chatgpt_account, - has_codex_backend_auth, - model_catalog: model_catalog.clone(), - feedback: feedback.clone(), - is_first_run, - status_account_display: status_account_display.clone(), - runtime_model_provider_base_url: runtime_model_provider_base_url.clone(), - initial_plan_type, - model: config.model.clone(), - startup_tooltip_override: None, - status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), - terminal_title_invalid_items_warned: terminal_title_invalid_items_warned - .clone(), - session_telemetry: session_telemetry.clone(), - }; - (ChatWidget::new_with_app_event(init), Some(resumed)) - } - SessionSelection::Fork(target_session) => { - session_telemetry.counter( - "codex.thread.fork", - /*inc*/ 1, - &[("source", "cli_subcommand")], - ); - let forked = app_server - .fork_thread(config.clone(), target_session.thread_id) - .await - .map_err(|err| session_start_error("fork", &target_session, err))?; - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.clone(), - workspace_command_runner: Some(workspace_command_runner.clone()), - initial_user_message: crate::chatwidget::create_initial_user_message( - initial_prompt.clone(), - initial_images.clone(), - // CLI prompt args are plain strings, so they don't provide element ranges. - Vec::new(), - ), - enhanced_keys_supported, - has_chatgpt_account, - has_codex_backend_auth, - model_catalog: model_catalog.clone(), - feedback: feedback.clone(), - is_first_run, - status_account_display: status_account_display.clone(), - runtime_model_provider_base_url: runtime_model_provider_base_url.clone(), - initial_plan_type, - model: config.model.clone(), - startup_tooltip_override: None, - status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), - terminal_title_invalid_items_warned: terminal_title_invalid_items_warned - .clone(), - session_telemetry: session_telemetry.clone(), - }; - (ChatWidget::new_with_app_event(init), Some(forked)) - } - }; - chat_widget.note_rendered_width(tui.terminal.last_known_screen_size.width); - chat_widget.remote_connection = remote_connection; - let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis(); - chat_widget - .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); - - let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); - let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).map_err(|err| { - color_eyre::eyre::eyre!( - "Invalid `tui.keymap` configuration: {err}\n\ -Fix the config and retry.\n\ -See the Codex keymap documentation for supported actions and examples." - ) - })?; - #[cfg(not(debug_assertions))] - let upgrade_version = crate::updates::get_upgrade_version(&config); - - let mut app = Self { - model_catalog, - session_telemetry: session_telemetry.clone(), - app_event_tx, - chat_widget, - workspace_command_runner: Some(workspace_command_runner), - config, - launch_cwd, - state_db, - cli_kv_overrides, - harness_overrides, - loader_overrides, - cloud_config_bundle, - runtime_approval_policy_override: None, - runtime_permission_profile_override: None, - file_search, - enhanced_keys_supported, - keymap: runtime_keymap, - key_chord_matcher: KeyChordMatcher::default(), - transcript_cells: Vec::new(), - last_rendered_history_tail: None, - last_thread_usage_status_cell: None, - pending_thread_usage_history_refresh: false, - overlay: None, - deferred_history_lines: Vec::new(), - has_emitted_history_lines: false, - transcript_reflow: TranscriptReflowState::default(), - initial_history_replay_buffer: None, - scrollback_has_older_history: false, - commit_anim_running: Arc::new(AtomicBool::new(false)), - status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), - terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), - skill_load_warnings: SkillLoadWarningState::default(), - backtrack: BacktrackState::default(), - backtrack_render_pending: false, - feedback: feedback.clone(), - feedback_audience, - environment_manager, - app_server_target, - pending_update_action: None, - pending_shutdown_exit_thread_id: None, - windows_sandbox: WindowsSandboxState::default(), - thread_event_channels: HashMap::new(), - thread_event_listener_tasks: HashMap::new(), - agent_navigation: AgentNavigationState::default(), - side_threads: HashMap::new(), - abandoned_side_threads: HashSet::new(), - active_thread_id: None, - active_thread_rx: None, - primary_thread_id: None, - last_subagent_backfill_attempt: None, - primary_session_configured: None, - pending_primary_events: VecDeque::new(), - pending_app_server_requests: PendingAppServerRequests::default(), - pending_startup_thread_start, - rate_limit_hard_stop_generation: 0, - pending_plugin_enabled_writes: HashMap::new(), - pending_hook_enabled_writes: HashMap::new(), - }; - if let Some(entry) = startup_hooks_browser { - app.chat_widget.open_hooks_browser(entry); - } - app.update_visible_history_rows(tui.terminal.last_known_screen_size); - let initial_session_started_at = Instant::now(); - if let Some(started) = initial_started_thread { - let thread_id = started.session.thread_id; - if started.blocks_direct_input { - app.mark_primary_thread_parent_owned(thread_id); - } - app.enqueue_primary_thread_session(started.session, started.turns) - .await?; - if should_prompt_for_paused_goal_after_startup_resume { - app.maybe_prompt_resume_paused_goal_after_resume(&mut app_server, thread_id) - .await; - } - } - let initial_session_ms = initial_session_started_at.elapsed().as_millis(); - - // On startup, if a managed filesystem sandbox is active, warn about - // world-writable dirs on Windows. - #[cfg(target_os = "windows")] - { - let startup_permission_profile = app.config.permissions.effective_permission_profile(); - let should_check = crate::windows_sandbox::level_from_config(&app.config) - != WindowsSandboxLevel::Disabled - && managed_filesystem_sandbox_is_restricted(&startup_permission_profile) - && !app - .config - .notices - .hide_world_writable_warning - .unwrap_or(false); - if should_check { - let cwd = app.config.cwd.clone(); - let workspace_roots = app.config.effective_workspace_roots(); - let env_map: std::collections::HashMap = std::env::vars().collect(); - let tx = app.app_event_tx.clone(); - let logs_base_dir = app.config.codex_home.clone(); - Self::spawn_world_writable_scan( - cwd, - workspace_roots, - env_map, - logs_base_dir, - startup_permission_profile, - tx, - ); - } - } - - let event_stream_started_at = Instant::now(); - let tui_events = tui.event_stream(); - tokio::pin!(tui_events); - - tui.schedule_screen_size_recheck(Duration::ZERO); - tracing::info!( - duration_ms = %(startup_elapsed_before_app + startup_started_at.elapsed()).as_millis(), - bootstrap_ms = %bootstrap_ms, - runtime_model_provider_ms = %runtime_model_provider_ms, - thread_and_widget_ms = %thread_and_widget_ms, - initial_session_ms = %initial_session_ms, - event_stream_ms = %event_stream_started_at.elapsed().as_millis(), - "tui startup initial frame scheduled" - ); - app.refresh_startup_skills(&app_server); - // Kick off a non-blocking rate-limit prefetch so the first `/status` - // already has data and available reset credits can be surfaced, without - // delaying the initial frame render. - if requires_openai_auth && has_chatgpt_account { - let reset_hint_request_id = app.chat_widget.start_rate_limit_reset_startup_check(); - app.refresh_rate_limits( - &app_server, - RateLimitRefreshOrigin::StartupPrefetch { - reset_hint_request_id, - }, - ); - } - - let mut listen_for_app_server_events = true; - let mut waiting_for_initial_session_configured = wait_for_initial_session_configured; - let mut waiting_for_initial_session_header = true; - - #[cfg(not(debug_assertions))] - let pre_loop_exit_reason = if let Some(latest_version) = upgrade_version { - let control = Box::pin(app.handle_event( - tui, - &mut app_server, - AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( - latest_version, - crate::update_action::get_update_action(), - ))), - )) - .await?; - match control { - AppRunControl::Continue => None, - AppRunControl::Exit(exit_reason) => Some(exit_reason), - } - } else { - None - }; - #[cfg(debug_assertions)] - let pre_loop_exit_reason: Option = None; - - let exit_reason_result = if let Some(exit_reason) = pre_loop_exit_reason { - Ok(exit_reason) - } else { - loop { - let initial_session_header_pending = waiting_for_initial_session_header - && app.primary_session_configured.is_some() - && !app_event_rx.is_empty(); - let control = select! { - Some(event) = app_event_rx.recv() => { - let is_initial_session_header = matches!( - &event, - AppEvent::InsertHistoryCell(cell) - if cell.as_any().is::() - ); - match Box::pin(app.handle_event(tui, &mut app_server, event)).await { - Ok(control) => { - if is_initial_session_header { - waiting_for_initial_session_header = false; - } - control - } - Err(err) => break Err(err), - } - } - active = async { - if let Some(rx) = app.active_thread_rx.as_mut() { - rx.recv().await - } else { - None - } - }, if App::should_handle_active_thread_events( - waiting_for_initial_session_configured, - app.active_thread_rx.is_some() - ) => { - if let Some(event) = active { - if let Err(err) = app.handle_active_thread_event(tui, &mut app_server, event).await { - break Err(err); - } - } else { - app.clear_active_thread().await; - } - AppRunControl::Continue - } - event = tui_events.next(), if !initial_session_header_pending => { - if let Some(event) = event { - match app.handle_tui_event(tui, &mut app_server, event).await { - Ok(control) => control, - Err(err) => break Err(err), - } - } else { - tracing::warn!("terminal input stream closed; shutting down active thread"); - app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst).await - } - } - app_server_event = app_server.next_event(), if listen_for_app_server_events => { - match app_server_event { - Some(event) => app.handle_app_server_event(&app_server, event).await, - None => { - listen_for_app_server_events = false; - tracing::warn!("app-server event stream closed"); - } - } - AppRunControl::Continue - } - }; - if App::should_stop_waiting_for_initial_session( - waiting_for_initial_session_configured, - app.primary_thread_id, - ) { - waiting_for_initial_session_configured = false; - } - match control { - AppRunControl::Continue => {} - AppRunControl::Exit(reason) => break Ok(reason), - } - } - }; - if let Err(err) = app_server.shutdown().await { - tracing::warn!(error = %err, "failed to shut down embedded app server"); - } - let clear_pet_result = tui.clear_ambient_pet_image(); - let clear_result = tui.terminal.clear(); - let exit_reason = match exit_reason_result { - Ok(exit_reason) => { - clear_pet_result?; - clear_result?; - exit_reason - } - Err(err) => { - if let Err(clear_pet_err) = clear_pet_result { - tracing::warn!(error = %clear_pet_err, "failed to clear ambient pet image"); - } - if let Err(clear_err) = clear_result { - tracing::warn!(error = %clear_err, "failed to clear terminal UI"); - } - return Err(err); - } - }; - let thread_id = app.chat_widget.thread_id().or(app.primary_thread_id); - let resume_hint = resume_hint_for_resumable_thread( - thread_id, - app.chat_widget.thread_name(), - app.chat_widget.rollout_path().as_deref(), - ); - Ok(AppExitInfo { - token_usage: app.token_usage(), - thread_id, - resume_hint, - update_action: app.pending_update_action, - exit_reason, - }) - } - pub(crate) async fn handle_tui_event( &mut self, tui: &mut tui::Tui, @@ -1369,8 +801,16 @@ See the Codex keymap documentation for supported actions and examples." return Ok(AppRunControl::Continue); } // Allow widgets to process any pending timers before rendering. + let had_active_view = self.chat_widget.has_active_view(); self.chat_widget.pre_draw_tick(); let rendered_area = self.render_chat_widget_frame(tui, screen_size)?; + if !had_active_view + && self.chat_widget.has_active_view() + && self.startup_protected_input_boundary + { + tui.discard_pending_input_before_interactive_screen()?; + self.startup_pending_protected_request = false; + } if self.chat_widget.ambient_pet_image_enabled() { let ambient_pet_area = Rect::new( /*x*/ 0, diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 4877db289e..65536d6a65 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -87,6 +87,10 @@ impl App { .resolve_notification(¬ification.request_id) { self.chat_widget.dismiss_app_server_request(&request); + if self.startup_pending_protected_request { + self.startup_pending_protected_request = + self.chat_widget.has_pending_protected_request(); + } } } ServerNotification::McpServerStatusUpdated(_) => { diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs new file mode 100644 index 0000000000..97d5bb6977 --- /dev/null +++ b/codex-rs/tui/src/app/startup.rs @@ -0,0 +1,757 @@ +//! Startup bootstrap, draft handoff, and protected-input orchestration for the TUI app. +//! +//! 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. + +use super::*; + +async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) -> Option { + let provider = create_model_provider(provider.clone(), /*auth_manager*/ None); + match provider.runtime_base_url().await { + Ok(base_url) => base_url, + Err(err) => { + tracing::warn!(%err, "failed to resolve runtime model provider base URL for status"); + None + } + } +} + +fn spawn_startup_thread_start( + app_server: &AppServerSession, + config: Config, + app_event_tx: AppEventSender, +) { + let request_handle = app_server.request_handle(); + let thread_params_mode = app_server.thread_params_mode(); + let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf); + tokio::spawn(async move { + let result = crate::app_server_session::start_thread_with_request_handle( + request_handle, + config, + thread_params_mode, + remote_cwd_override, + ) + .await + .map_err(|err| format!("{err:#}")); + app_event_tx.send(AppEvent::StartupThreadStarted { result }); + }); +} + +impl App { + /// Recognizes queued requests before they become visible protected screens. + pub(super) fn has_queued_startup_protected_request(&self) -> bool { + self.startup_protected_input_boundary + && (self + .active_thread_rx + .as_ref() + .is_some_and(|receiver| !receiver.is_empty()) + || self + .pending_primary_events + .iter() + .any(|event| matches!(event, ThreadBufferedEvent::Request(_)))) + } + + #[allow(clippy::too_many_arguments)] + pub async fn run( + tui: &mut tui::Tui, + mut app_server: AppServerSession, + mut config: Config, + launch_cwd: PathBuf, + cli_kv_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, + loader_overrides: LoaderOverrides, + cloud_config_bundle: CloudConfigBundleLoader, + initial_prompt: Option, + initial_images: Vec, + session_selection: SessionSelection, + feedback: codex_feedback::CodexFeedback, + is_first_run: bool, + should_prompt_windows_sandbox_nux_at_startup: bool, + app_server_target: AppServerTarget, + state_db: Option, + environment_manager: Arc, + startup_elapsed_before_app: Duration, + startup_bootstrap: Option, + startup_hooks_browser: Option, + mut startup_draft: StartupDraftPump, + ) -> Result { + use tokio_stream::StreamExt; + + async fn shutdown_on_startup_error( + app_server: AppServerSession, + error: impl Into, + ) -> Result { + if let Err(shutdown_error) = app_server.shutdown().await { + tracing::warn!("app-server shutdown failed: {shutdown_error}"); + } + 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(()) + } + + let startup_started_at = Instant::now(); + let (app_event_tx, mut app_event_rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(app_event_tx); + emit_project_config_warnings(&app_event_tx, &config); + emit_system_bwrap_warning(&app_event_tx, &config); + tui.set_notification_settings( + config.tui_notifications.method, + config.tui_notifications.condition, + ); + + let harness_overrides = + normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; + let bootstrap = match startup_bootstrap { + Some(bootstrap) => bootstrap, + None => match startup_draft + .run_until(tui, app_server.bootstrap(&config)) + .await + { + Ok(bootstrap) => bootstrap?, + Err(err) => return shutdown_on_startup_error(app_server, err).await, + }, + }; + let bootstrap_ms = bootstrap.duration.as_millis(); + if matches!( + &session_selection, + SessionSelection::StartFresh | SessionSelection::Exit + ) { + apply_managed_new_thread_defaults( + &mut config, + app_server.managed_new_thread_defaults(), + &cli_kv_overrides, + &harness_overrides, + ); + } + let mut model = config.model.clone().unwrap_or(bootstrap.default_model); + let available_models = bootstrap.available_models; + let remote_connection = crate::status::remote_connection::remote_connection_status_value( + &app_server_target, + app_server.server_version(), + ); + if let Err(err) = startup_draft.flush_pending_events(tui).await { + return shutdown_on_startup_error(app_server, err).await; + } + let exit_info = handle_model_migration_prompt_if_needed( + tui, + &mut config, + model.as_str(), + &app_event_tx, + &available_models, + ) + .await?; + if let Some(exit_info) = exit_info { + app_server + .shutdown() + .await + .inspect_err(|err| { + tracing::warn!("app-server shutdown failed: {err}"); + }) + .ok(); + return Ok(exit_info); + } + if let Some(updated_model) = config.model.clone() { + model = updated_model; + } + let model_catalog = Arc::new(ModelCatalog::new(available_models.clone())); + let feedback_audience = bootstrap.feedback_audience; + let auth_mode = bootstrap.auth_mode; + let has_chatgpt_account = bootstrap.has_chatgpt_account; + let has_codex_backend_auth = matches!(auth_mode, Some(TelemetryAuthMode::Chatgpt)); + let requires_openai_auth = bootstrap.requires_openai_auth; + let status_account_display = bootstrap.status_account_display.clone(); + let initial_plan_type = bootstrap.plan_type; + let session_telemetry = SessionTelemetry::new( + ThreadId::new(), + model.as_str(), + model.as_str(), + /*account_id*/ None, + bootstrap.account_email.clone(), + auth_mode, + codex_login::default_client::originator().value, + config.otel.log_user_prompt, + user_agent(), + serde_json::from_value(serde_json::json!("cli")) + .unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")), + ); + if config + .tui_status_line + .as_ref() + .is_some_and(|cmd| !cmd.is_empty()) + { + session_telemetry.counter("codex.status_line", /*inc*/ 1, &[]); + } + + let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); + let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false)); + let workspace_command_runner: WorkspaceCommandRunner = Arc::new( + AppServerWorkspaceCommandRunner::new(app_server.request_handle()), + ); + let runtime_model_provider_started_at = Instant::now(); + let runtime_model_provider_base_url = match startup_draft + .run_until( + tui, + resolve_runtime_model_provider_base_url(&config.model_provider), + ) + .await + { + Ok(base_url) => base_url, + Err(err) => return shutdown_on_startup_error(app_server, err).await, + }; + let runtime_model_provider_ms = runtime_model_provider_started_at.elapsed().as_millis(); + + let enhanced_keys_supported = tui.enhanced_keys_supported(); + let wait_for_initial_session_configured = + Self::should_wait_for_initial_session(&session_selection); + let should_prompt_for_paused_goal_after_startup_resume = + Self::should_prompt_for_paused_goal_after_startup_resume( + &session_selection, + &initial_prompt, + &initial_images, + ); + let thread_and_widget_started_at = Instant::now(); + let pending_startup_thread_start = matches!( + &session_selection, + SessionSelection::StartFresh | SessionSelection::Exit + ); + let (mut chat_widget, initial_started_thread) = match session_selection { + SessionSelection::StartFresh | SessionSelection::Exit => { + spawn_startup_thread_start(&app_server, config.clone(), app_event_tx.clone()); + // Count a startup tooltip once the initial chat widget can render it. + let startup_tooltip_override = match startup_draft + .run_until( + tui, + prepare_startup_tooltip_override( + &mut config, + &available_models, + is_first_run, + ), + ) + .await + { + Ok(tooltip_override) => tooltip_override, + Err(err) => return shutdown_on_startup_error(app_server, err).await, + }; + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + workspace_command_runner: Some(workspace_command_runner.clone()), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), + enhanced_keys_supported, + has_chatgpt_account, + has_codex_backend_auth, + model_catalog: model_catalog.clone(), + feedback: feedback.clone(), + is_first_run, + status_account_display: status_account_display.clone(), + runtime_model_provider_base_url: runtime_model_provider_base_url.clone(), + initial_plan_type, + model: Some(model.clone()), + startup_tooltip_override, + status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), + session_telemetry: session_telemetry.clone(), + }; + let mut chat_widget = ChatWidget::new_with_app_event(init); + chat_widget.set_queue_submissions_until_session_configured(/*queue*/ true); + (chat_widget, None) + } + SessionSelection::Resume(target_session) => { + if let Some(history_mode) = target_session.history_mode { + app_server.remember_thread_history_mode(target_session.thread_id, history_mode); + } + let model_settings = config_persistence::resume_model_settings_for_overrides( + &config, + &harness_overrides, + ); + let resumed = match startup_draft + .run_until( + tui, + app_server.resume_thread( + config.clone(), + target_session.thread_id, + model_settings, + ), + ) + .await + { + Ok(resumed) => resumed + .map_err(|err| session_start_error("resume", &target_session, err))?, + Err(err) => return shutdown_on_startup_error(app_server, err).await, + }; + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + workspace_command_runner: Some(workspace_command_runner.clone()), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), + enhanced_keys_supported, + has_chatgpt_account, + has_codex_backend_auth, + model_catalog: model_catalog.clone(), + feedback: feedback.clone(), + is_first_run, + status_account_display: status_account_display.clone(), + runtime_model_provider_base_url: runtime_model_provider_base_url.clone(), + initial_plan_type, + model: config.model.clone(), + startup_tooltip_override: None, + status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), + session_telemetry: session_telemetry.clone(), + }; + (ChatWidget::new_with_app_event(init), Some(resumed)) + } + SessionSelection::Fork(target_session) => { + session_telemetry.counter( + "codex.thread.fork", + /*inc*/ 1, + &[("source", "cli_subcommand")], + ); + let forked = match startup_draft + .run_until( + tui, + app_server.fork_thread(config.clone(), target_session.thread_id), + ) + .await + { + Ok(forked) => { + forked.map_err(|err| session_start_error("fork", &target_session, err))? + } + Err(err) => return shutdown_on_startup_error(app_server, err).await, + }; + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + workspace_command_runner: Some(workspace_command_runner.clone()), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), + enhanced_keys_supported, + has_chatgpt_account, + has_codex_backend_auth, + model_catalog: model_catalog.clone(), + feedback: feedback.clone(), + is_first_run, + status_account_display: status_account_display.clone(), + runtime_model_provider_base_url: runtime_model_provider_base_url.clone(), + initial_plan_type, + model: config.model.clone(), + startup_tooltip_override: None, + status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned + .clone(), + session_telemetry: session_telemetry.clone(), + }; + (ChatWidget::new_with_app_event(init), Some(forked)) + } + }; + chat_widget.note_rendered_width(tui.terminal.last_known_screen_size.width); + chat_widget.remote_connection = remote_connection; + let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis(); + chat_widget + .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); + + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); + let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).map_err(|err| { + color_eyre::eyre::eyre!( + "Invalid `tui.keymap` configuration: {err}\n\ +Fix the config and retry.\n\ +See the Codex keymap documentation for supported actions and examples." + ) + })?; + #[cfg(not(debug_assertions))] + let upgrade_version = crate::updates::get_upgrade_version(&config); + + let mut app = Self { + model_catalog, + session_telemetry: session_telemetry.clone(), + app_event_tx, + chat_widget, + workspace_command_runner: Some(workspace_command_runner), + config, + launch_cwd, + state_db, + cli_kv_overrides, + harness_overrides, + loader_overrides, + cloud_config_bundle, + runtime_approval_policy_override: None, + runtime_permission_profile_override: None, + file_search, + enhanced_keys_supported, + keymap: runtime_keymap, + key_chord_matcher: KeyChordMatcher::default(), + transcript_cells: Vec::new(), + last_rendered_history_tail: None, + last_thread_usage_status_cell: None, + pending_thread_usage_history_refresh: false, + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + transcript_reflow: TranscriptReflowState::default(), + initial_history_replay_buffer: None, + scrollback_has_older_history: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), + terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), + skill_load_warnings: SkillLoadWarningState::default(), + backtrack: BacktrackState::default(), + backtrack_render_pending: false, + feedback: feedback.clone(), + feedback_audience, + environment_manager, + app_server_target, + pending_update_action: None, + pending_shutdown_exit_thread_id: None, + windows_sandbox: WindowsSandboxState::default(), + thread_event_channels: HashMap::new(), + thread_event_listener_tasks: HashMap::new(), + agent_navigation: AgentNavigationState::default(), + side_threads: HashMap::new(), + abandoned_side_threads: HashSet::new(), + active_thread_id: None, + active_thread_rx: None, + primary_thread_id: None, + last_subagent_backfill_attempt: None, + primary_session_configured: None, + pending_primary_events: VecDeque::new(), + pending_app_server_requests: PendingAppServerRequests::default(), + pending_startup_thread_start, + startup_protected_input_boundary: true, + startup_pending_protected_request: false, + rate_limit_hard_stop_generation: 0, + pending_plugin_enabled_writes: HashMap::new(), + pending_hook_enabled_writes: HashMap::new(), + }; + if let Some(entry) = startup_hooks_browser { + app.chat_widget.open_hooks_browser(entry); + } + app.update_visible_history_rows(tui.terminal.last_known_screen_size); + let initial_session_started_at = Instant::now(); + if let Some(started) = initial_started_thread { + let thread_id = started.session.thread_id; + if started.blocks_direct_input { + app.mark_primary_thread_parent_owned(thread_id); + } + match startup_draft + .run_until( + tui, + app.enqueue_primary_thread_session(started.session, started.turns), + ) + .await + { + Ok(result) => result?, + Err(err) => return shutdown_on_startup_error(app_server, err).await, + } + if should_prompt_for_paused_goal_after_startup_resume + && let Err(err) = startup_draft + .run_until( + tui, + app.maybe_prompt_resume_paused_goal_after_resume( + &mut app_server, + thread_id, + ), + ) + .await + { + return shutdown_on_startup_error(app_server, err).await; + } + } + let initial_session_ms = initial_session_started_at.elapsed().as_millis(); + + // On startup, if a managed filesystem sandbox is active, warn about + // world-writable dirs on Windows. + #[cfg(target_os = "windows")] + { + let startup_permission_profile = app.config.permissions.effective_permission_profile(); + let should_check = crate::windows_sandbox::level_from_config(&app.config) + != WindowsSandboxLevel::Disabled + && managed_filesystem_sandbox_is_restricted(&startup_permission_profile) + && !app + .config + .notices + .hide_world_writable_warning + .unwrap_or(false); + if should_check { + let cwd = app.config.cwd.clone(); + let workspace_roots = app.config.effective_workspace_roots(); + let env_map: std::collections::HashMap = std::env::vars().collect(); + let tx = app.app_event_tx.clone(); + let logs_base_dir = app.config.codex_home.clone(); + Self::spawn_world_writable_scan( + cwd, + workspace_roots, + env_map, + logs_base_dir, + startup_permission_profile, + tx, + ); + } + } + + if let Err(err) = startup_draft.flush_pending_events(tui).await { + return shutdown_on_startup_error(app_server, err).await; + } + if Self::should_handle_active_thread_events( + wait_for_initial_session_configured, + app.active_thread_rx.is_some(), + ) && let Err(err) = app.drain_active_thread_events(tui).await + { + return shutdown_on_startup_error(app_server, err).await; + } + if app_event_rx.is_empty() + && !app.has_queued_startup_protected_request() + && !app.startup_pending_protected_request + && !app.chat_widget.has_active_view() + && !app.chat_widget.has_pending_protected_request() + && let Err(err) = startup_draft.flush_pending_paste_newline(tui).await + { + return shutdown_on_startup_error(app_server, err).await; + } + 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); + } + + let event_stream_started_at = Instant::now(); + tui.schedule_screen_size_recheck(Duration::ZERO); + if let Err(err) = render_startup_frame(&mut app, tui) { + return shutdown_on_startup_error(app_server, err).await; + } + let tui_events = tui.event_stream(); + tokio::pin!(tui_events); + tracing::info!( + duration_ms = %(startup_elapsed_before_app + startup_started_at.elapsed()).as_millis(), + bootstrap_ms = %bootstrap_ms, + runtime_model_provider_ms = %runtime_model_provider_ms, + thread_and_widget_ms = %thread_and_widget_ms, + initial_session_ms = %initial_session_ms, + event_stream_ms = %event_stream_started_at.elapsed().as_millis(), + "tui startup initial frame scheduled" + ); + app.refresh_startup_skills(&app_server); + // Kick off a non-blocking rate-limit prefetch so the first `/status` + // already has data and available reset credits can be surfaced, without + // delaying the initial frame render. + if requires_openai_auth && has_chatgpt_account { + let reset_hint_request_id = app.chat_widget.start_rate_limit_reset_startup_check(); + app.refresh_rate_limits( + &app_server, + RateLimitRefreshOrigin::StartupPrefetch { + reset_hint_request_id, + }, + ); + } + + let mut listen_for_app_server_events = true; + let mut waiting_for_initial_session_configured = wait_for_initial_session_configured; + let mut waiting_for_initial_session_header = true; + + #[cfg(not(debug_assertions))] + let pre_loop_exit_reason = if let Some(latest_version) = upgrade_version { + let control = Box::pin(app.handle_event( + tui, + &mut app_server, + AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( + latest_version, + crate::update_action::get_update_action(), + ))), + )) + .await?; + match control { + AppRunControl::Continue => None, + AppRunControl::Exit(exit_reason) => Some(exit_reason), + } + } else { + None + }; + #[cfg(debug_assertions)] + let pre_loop_exit_reason: Option = None; + + let exit_reason_result = if let Some(exit_reason) = pre_loop_exit_reason { + Ok(exit_reason) + } else { + loop { + let initial_session_header_pending = waiting_for_initial_session_header + && app.primary_session_configured.is_some() + && !app_event_rx.is_empty(); + let block_terminal_input_for_pending_startup_events = initial_session_header_pending + || (pending_startup_draft.is_some() || app.startup_protected_input_boundary) + && !app_event_rx.is_empty() + || (!waiting_for_initial_session_configured + && app.has_queued_startup_protected_request()); + let control = select! { + Some(event) = app_event_rx.recv() => { + let is_initial_session_header = matches!( + &event, + AppEvent::InsertHistoryCell(cell) + if cell.as_any().is::() + ); + let had_active_view = app.chat_widget.has_active_view(); + match Box::pin(app.handle_event(tui, &mut app_server, event)).await { + Ok(AppRunControl::Continue) => { + if is_initial_session_header { + waiting_for_initial_session_header = false; + } + if !had_active_view + && app.chat_widget.has_active_view() + && let Err(err) = render_startup_frame(&mut app, tui) + { + break Err(err); + } + AppRunControl::Continue + } + Ok(AppRunControl::Exit(reason)) => AppRunControl::Exit(reason), + Err(err) => break Err(err), + } + } + active = async { + if let Some(rx) = app.active_thread_rx.as_mut() { + rx.recv().await + } else { + None + } + }, if App::should_handle_active_thread_events( + waiting_for_initial_session_configured, + app.active_thread_rx.is_some() + ) => { + if let Some(event) = active { + if let Err(err) = app.handle_active_thread_event(tui, &mut app_server, event).await { + break Err(err); + } + } else { + app.clear_active_thread().await; + } + AppRunControl::Continue + } + event = tui_events.next(), if !block_terminal_input_for_pending_startup_events => { + if let Some(event) = event { + if (matches!( + &event, + TuiEvent::Key(key) + if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) + ) || matches!(&event, TuiEvent::Paste(_))) + && pending_startup_draft.is_none() + && !waiting_for_initial_session_configured + && app_event_rx.is_empty() + && !app.startup_pending_protected_request + && app + .active_thread_rx + .as_ref() + .is_none_or(tokio::sync::mpsc::Receiver::is_empty) + && !app.pending_primary_events.iter().any(|event| { + matches!(event, ThreadBufferedEvent::Request(_)) + }) + { + app.startup_protected_input_boundary = false; + } + match app.handle_tui_event(tui, &mut app_server, event).await { + Ok(control) => control, + Err(err) => break Err(err), + } + } else { + tracing::warn!("terminal input stream closed; shutting down active thread"); + app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst).await + } + } + app_server_event = app_server.next_event(), if listen_for_app_server_events => { + match app_server_event { + Some(event) => app.handle_app_server_event(&app_server, event).await, + None => { + listen_for_app_server_events = false; + tracing::warn!("app-server event stream closed"); + } + } + AppRunControl::Continue + } + }; + if App::should_stop_waiting_for_initial_session( + waiting_for_initial_session_configured, + app.primary_thread_id, + ) { + waiting_for_initial_session_configured = false; + let had_active_view = app.chat_widget.has_active_view(); + if let Err(err) = app.drain_active_thread_events(tui).await { + break Err(err); + } + if !had_active_view + && app.chat_widget.has_active_view() + && let Err(err) = render_startup_frame(&mut app, tui) + { + break Err(err); + } + } + match control { + AppRunControl::Continue => { + if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() { + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + } + } + AppRunControl::Exit(reason) => break Ok(reason), + } + } + }; + if let Err(err) = app_server.shutdown().await { + tracing::warn!(error = %err, "failed to shut down embedded app server"); + } + let clear_pet_result = tui.clear_ambient_pet_image(); + let clear_result = tui.terminal.clear(); + let exit_reason = match exit_reason_result { + Ok(exit_reason) => { + clear_pet_result?; + clear_result?; + exit_reason + } + Err(err) => { + if let Err(clear_pet_err) = clear_pet_result { + tracing::warn!(error = %clear_pet_err, "failed to clear ambient pet image"); + } + if let Err(clear_err) = clear_result { + tracing::warn!(error = %clear_err, "failed to clear terminal UI"); + } + return Err(err); + } + }; + let thread_id = app.chat_widget.thread_id().or(app.primary_thread_id); + let resume_hint = resume_hint_for_resumable_thread( + thread_id, + app.chat_widget.thread_name(), + app.chat_widget.rollout_path().as_deref(), + ); + Ok(AppExitInfo { + token_usage: app.token_usage(), + thread_id, + resume_hint, + update_action: app.pending_update_action, + exit_reason, + }) + } +} diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index c61caf53c6..45ab3116cc 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -71,6 +71,8 @@ pub(super) async fn make_test_app() -> App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_startup_thread_start: false, + startup_protected_input_boundary: false, + startup_pending_protected_request: false, rate_limit_hard_stop_generation: 0, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 17e4b963aa..6186ca8267 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4843,6 +4843,8 @@ async fn make_test_app() -> App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_startup_thread_start: false, + startup_protected_input_boundary: false, + startup_pending_protected_request: false, rate_limit_hard_stop_generation: 0, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), @@ -4916,6 +4918,8 @@ async fn make_test_app_with_channels() -> ( pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_startup_thread_start: false, + startup_protected_input_boundary: false, + startup_pending_protected_request: false, rate_limit_hard_stop_generation: 0, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index ffbdfc3b41..53ae2f2a02 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -1,8 +1,33 @@ use super::*; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPane; +use crate::bottom_pane::BottomPaneParams; +use crate::tui::FrameRequester; +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputQuestion; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::mpsc::unbounded_channel; + +fn startup_bottom_pane() -> (BottomPane, UnboundedReceiver) { + let (app_event_tx, app_event_rx) = unbounded_channel(); + ( + BottomPane::new(BottomPaneParams { + app_event_tx: AppEventSender::new(app_event_tx), + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: true, + animations_enabled: true, + skills: None, + }), + app_event_rx, + ) +} #[test] fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() { @@ -141,6 +166,639 @@ fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() { ); } +#[tokio::test] +async fn queued_startup_requests_block_terminal_input_after_draft_handoff() -> Result<()> { + for user_input in [false, true] { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + while app_event_rx.try_recv().is_ok() {} + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new()); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert!(pending_startup_draft.is_none()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft"); + assert!(!app.has_queued_startup_protected_request()); + + let request = if user_input { + request_user_input_request(thread_id, "turn-1", "call-1") + } else { + exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None) + }; + let _ = app + .pending_app_server_requests + .note_server_request(&request); + app.enqueue_primary_thread_request(request).await?; + + assert!( + app.active_thread_rx + .as_ref() + .is_some_and(|receiver| !receiver.is_empty()) + ); + assert!( + app.has_queued_startup_protected_request(), + "a queued protected startup request must be handled before terminal input" + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft"); + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!( + op, + Op::UserTurn { .. } | Op::ExecApproval { .. } | Op::UserInputAnswer { .. } + ), + "terminal input must not submit or answer the queued protected request: {op:?}" + ); + } + + let event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("protected request should be queued on the active thread"); + app.handle_thread_event_now(event); + + assert!( + !app.has_queued_startup_protected_request(), + "terminal polling must resume after the protected event is drained" + ); + assert!(app.startup_protected_input_boundary); + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft"); + + app.startup_protected_input_boundary = false; + app.enqueue_primary_thread_request(exec_approval_request( + thread_id, "turn-2", "call-2", /*approval_id*/ None, + )) + .await?; + assert!( + !app.has_queued_startup_protected_request(), + "ordinary post-startup protected requests must not block terminal input" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn queued_startup_requests_block_draft_restore_until_drained() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + let thread_id = ThreadId::new(); + let request = exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); + let _ = app + .pending_app_server_requests + .note_server_request(&request); + app.enqueue_primary_thread_request(request).await?; + + assert!(app.has_queued_startup_protected_request()); + + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + while app_event_rx.try_recv().is_ok() {} + assert!(app.has_queued_startup_protected_request()); + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new()); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() { + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + } + + assert!(pending_startup_draft.is_some()); + assert!(app.chat_widget.composer_is_empty()); + + let event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("protected request should be queued on the active thread"); + app.handle_thread_event_now(event); + + assert!(!app.has_queued_startup_protected_request()); + assert!(app.chat_widget.has_active_view()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert!(pending_startup_draft.is_some()); + assert!(app.chat_widget.composer_is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn startup_draft_handoff_keeps_approval_shortcuts_in_recently_active_composer() -> Result<()> +{ + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + + assert!(pending_startup_draft.is_none()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "x"); + + let approval_request = + exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); + let _ = app + .pending_app_server_requests + .note_server_request(&approval_request); + app.enqueue_primary_thread_request(approval_request).await?; + let approval_event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("approval should be queued on the active thread"); + app.handle_thread_event_now(approval_event); + + assert!(!app.chat_widget.has_active_view()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(app.chat_widget.composer_text_with_pending(), "xy"); + + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!(event, AppEvent::SubmitThreadOp { .. }), + "startup typeahead should not approve a newly buffered request: {event:?}" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn startup_draft_delayed_approval_becomes_protected_on_redraw() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new()); + let mut draft = startup_pane.composer_draft_snapshot(); + draft.last_composer_activity_at = Some(Instant::now() - Duration::from_millis(/*millis*/ 950)); + let mut pending_startup_draft = Some(draft); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + + let approval_request = + exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); + let _ = app + .pending_app_server_requests + .note_server_request(&approval_request); + app.enqueue_primary_thread_request(approval_request).await?; + let approval_event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("approval should be queued on the active thread"); + app.handle_thread_event_now(approval_event); + assert!(!app.chat_widget.has_active_view()); + assert!(app.startup_pending_protected_request); + + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), + ) + .await?; + assert!(app.startup_protected_input_boundary); + assert!(app.startup_pending_protected_request); + + tokio::time::sleep(Duration::from_millis(/*millis*/ 75)).await; + let redraw_result = app + .handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) + .await; + + assert!(app.chat_widget.has_active_view()); + assert!(!tui.terminal.viewport_area.is_empty()); + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!(event, AppEvent::SubmitThreadOp { .. }), + "revealing a delayed startup approval must not submit a decision: {event:?}" + ); + } + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::ExecApproval { .. } | Op::UserInputAnswer { .. }), + "revealing a delayed startup approval must not answer it: {op:?}" + ); + } + if let Err(error) = redraw_result { + tracing::debug!(error = %error, "test terminal cannot quarantine interactive input"); + } else { + assert!(!app.startup_pending_protected_request); + } + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn remotely_resolved_startup_approvals_release_the_draft_after_the_last_request() -> Result<()> +{ + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + let app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + app.chat_widget.handle_paste("x".to_string()); + + for (request_id, item_id) in [(1, "call-1"), (2, "call-2")] { + let mut request = + exec_approval_request(thread_id, "turn-1", item_id, /*approval_id*/ None); + let ServerRequest::CommandExecutionRequestApproval { + request_id: approval_request_id, + .. + } = &mut request + else { + unreachable!("exec approval helper should return an exec approval"); + }; + *approval_request_id = AppServerRequestId::Integer(request_id); + let _ = app + .pending_app_server_requests + .note_server_request(&request); + app.handle_thread_event_now(ThreadBufferedEvent::Request(Box::new(request))); + } + + assert!(!app.chat_widget.has_active_view()); + assert!(app.startup_pending_protected_request); + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("startup draft".to_string(), Vec::new(), Vec::new()); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert!(pending_startup_draft.is_some()); + + for (request_id, still_pending) in [(1, true), (2, false)] { + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::ServerRequestResolved( + codex_app_server_protocol::ServerRequestResolvedNotification { + thread_id: thread_id.to_string(), + request_id: AppServerRequestId::Integer(request_id), + }, + ), + )), + ) + .await; + + assert_eq!(app.startup_pending_protected_request, still_pending); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert_eq!(pending_startup_draft.is_some(), still_pending); + } + + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "x\nstartup draft" + ); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn later_thread_approval_preserves_input_after_startup_boundary_ends() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + + assert!(!app.startup_protected_input_boundary); + app.chat_widget + .apply_external_edit("keep this draft".to_string()); + let approval_request = + exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); + let _ = app + .pending_app_server_requests + .note_server_request(&approval_request); + app.enqueue_primary_thread_request(approval_request).await?; + let approval_event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("approval should be queued on the active thread"); + assert!(!app.chat_widget.has_active_view()); + + app.handle_active_thread_event(&mut tui, &mut app_server, approval_event) + .await?; + + assert!(app.chat_widget.has_active_view()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "keep this draft" + ); + assert!(tui.terminal.viewport_area.is_empty()); + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!(event, AppEvent::SubmitThreadOp { .. }), + "a later protected request must not consume existing input: {event:?}" + ); + } + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::ExecApproval { .. } | Op::UserInputAnswer { .. }), + "a later protected request must not submit an answer: {op:?}" + ); + } + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn auto_declined_mcp_elicitations_do_not_leave_startup_quarantine_armed() { + for replay in [false, true] { + for elicitation in [ + McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "http://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + McpServerElicitationRequest::OpenAiForm { + meta: None, + message: "Choose a report.".to_string(), + requested_schema: serde_json::json!({}), + }, + ] { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + let thread_id = ThreadId::new(); + let request = ServerRequest::McpServerElicitationRequest { + request_id: AppServerRequestId::Integer(10), + params: McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: elicitation, + }, + }; + let _ = app + .pending_app_server_requests + .note_server_request(&request); + let event = ThreadBufferedEvent::Request(Box::new(request)); + if replay { + app.handle_thread_event_replay(event); + } else { + app.handle_thread_event_now(event); + } + + assert!(!app.chat_widget.has_active_view()); + assert!(!app.startup_pending_protected_request); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op: Op::ResolveElicitation { + server_name, + request_id: AppServerRequestId::Integer(10), + decision: codex_app_server_protocol::McpServerElicitationAction::Decline, + content: None, + meta: None, + }, + }) if op_thread_id == thread_id && server_name == "payments" + ); + } + } +} + +#[tokio::test] +async fn startup_draft_handoff_recognizes_late_user_input_as_new_protected_view() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + + let mut request = request_user_input_request(thread_id, "turn-1", "call-1"); + let ServerRequest::ToolRequestUserInput { params, .. } = &mut request else { + panic!("expected a user input request"); + }; + params.questions.push(ToolRequestUserInputQuestion { + id: "choice".to_string(), + header: "Pick one".to_string(), + question: "Choose an option.".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ToolRequestUserInputOption { + label: "First".to_string(), + description: "First option".to_string(), + }]), + }); + let _ = app + .pending_app_server_requests + .note_server_request(&request); + app.enqueue_primary_thread_request(request).await?; + let event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("user input request should be queued on the active thread"); + + assert!(!app.chat_widget.has_active_view()); + app.handle_thread_event_now(event); + + assert!(app.chat_widget.has_active_view()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "x"); + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!(event, AppEvent::CodexOp(Op::UserInputAnswer { .. })), + "showing the protected user input request must not submit an answer: {event:?}" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn fresh_startup_thread_drains_buffered_approval_before_draft_handoff() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.pending_startup_thread_start = true; + let thread_id = ThreadId::new(); + let approval_request = + exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); + let _ = app + .pending_app_server_requests + .note_server_request(&approval_request); + app.enqueue_primary_thread_request(approval_request).await?; + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("inspect @src".to_string(), Vec::new(), Vec::new()); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + let mut waiting_for_initial_session_configured = + App::should_wait_for_initial_session(&SessionSelection::StartFresh); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::StartupThreadStarted { + result: Ok(AppServerStartedThread { + session: test_thread_session(thread_id, test_path_buf("/tmp/project")), + turns: Vec::new(), + blocks_direct_input: false, + }), + }, + )) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert!( + app.active_thread_rx + .as_ref() + .is_some_and(|receiver| !receiver.is_empty()) + ); + assert!(!app.chat_widget.has_active_view()); + + if App::should_stop_waiting_for_initial_session( + waiting_for_initial_session_configured, + app.primary_thread_id, + ) { + waiting_for_initial_session_configured = false; + app.drain_active_thread_events(&mut tui).await?; + } + + assert!(!waiting_for_initial_session_configured); + assert!(app.chat_widget.has_active_view()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert_eq!( + pending_startup_draft + .as_ref() + .map(|draft| draft.text.as_str()), + Some("inspect @src") + ); + assert!(app.chat_widget.composer_is_empty()); + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::ExecApproval { .. } | Op::UserInputAnswer { .. }), + "showing a protected startup approval must not answer it: {op:?}" + ); + } + + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!( + event, + AppEvent::StartFileSearch(_) | AppEvent::SubmitThreadOp { .. } + ), + "fresh startup approval must own input before draft side effects: {event:?}" + ); + } + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn queued_startup_app_event_owns_protected_view_before_draft_restore() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + while app_event_rx.try_recv().is_ok() {} + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("inspect @src".to_string(), Vec::new(), Vec::new()); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + app.app_event_tx.send(AppEvent::OpenApprovalsPopup); + + if app_event_rx.is_empty() { + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + } + assert!(pending_startup_draft.is_some()); + assert!(app.chat_widget.composer_is_empty()); + + let event = app_event_rx + .try_recv() + .expect("protected startup app event should be queued"); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + assert!(!app.chat_widget.has_active_view()); + let control = Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert!(app.chat_widget.has_active_view()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert_eq!( + pending_startup_draft + .as_ref() + .map(|draft| draft.text.as_str()), + Some("inspect @src") + ); + assert!(app.chat_widget.composer_is_empty()); + + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!( + event, + AppEvent::StartFileSearch(_) + | AppEvent::UpdateWorldWritableWarningAcknowledged(_) + | AppEvent::PersistWorldWritableWarningAcknowledged + ), + "protected startup app event must own input before draft side effects: {event:?}" + ); + } + + app_server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn startup_thread_started_submits_queued_startup_input() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 918bb091f0..cee5594f44 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -353,6 +353,9 @@ impl App { ThreadInteractiveRequest::Approval(request) => { self.render_inactive_patch_preview(&request); self.chat_widget.push_approval_request(request); + if self.startup_protected_input_boundary && !self.chat_widget.has_active_view() { + self.startup_pending_protected_request = true; + } } ThreadInteractiveRequest::McpServerElicitation(request) => { self.chat_widget @@ -1570,6 +1573,28 @@ impl App { self.chat_widget.handle_skills_list_response(response); } + fn startup_request_may_open_protected_view(&self, request: &ServerRequest) -> bool { + let ServerRequest::McpServerElicitationRequest { request_id, params } = request else { + return true; + }; + + match ¶ms.request { + codex_app_server_protocol::McpServerElicitationRequest::Form { .. } => true, + codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm { .. } => false, + request @ codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { + let thread_id = ThreadId::from_string(¶ms.thread_id) + .unwrap_or_else(|_| self.chat_widget.thread_id().unwrap_or_default()); + AppLinkViewParams::from_url_app_server_request( + thread_id, + ¶ms.server_name, + request_id.clone(), + request, + ) + .is_some() + } + } + } + pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) { let needs_refresh = matches!( &event, @@ -1591,8 +1616,16 @@ impl App { .pending_app_server_requests .contains_server_request(request.as_ref()) { + let may_open_protected_view = + self.startup_request_may_open_protected_view(request.as_ref()); self.chat_widget .handle_server_request(*request, /*replay_kind*/ None); + if may_open_protected_view + && self.startup_protected_input_boundary + && !self.chat_widget.has_active_view() + { + self.startup_pending_protected_request = true; + } } } ThreadBufferedEvent::HistoryEntryResponse(event) => { @@ -1612,9 +1645,18 @@ impl App { ThreadBufferedEvent::Notification(notification) => self .chat_widget .handle_server_notification(*notification, Some(ReplayKind::ThreadSnapshot)), - ThreadBufferedEvent::Request(request) => self - .chat_widget - .handle_server_request(*request, Some(ReplayKind::ThreadSnapshot)), + ThreadBufferedEvent::Request(request) => { + let may_open_protected_view = + self.startup_request_may_open_protected_view(request.as_ref()); + self.chat_widget + .handle_server_request(*request, Some(ReplayKind::ThreadSnapshot)); + if may_open_protected_view + && self.startup_protected_input_boundary + && !self.chat_widget.has_active_view() + { + self.startup_pending_protected_request = true; + } + } ThreadBufferedEvent::HistoryEntryResponse(event) => { self.chat_widget.handle_history_entry_response(event) } @@ -1686,7 +1728,17 @@ impl App { // thread, so unrelated shutdowns cannot consume this marker. self.pending_shutdown_exit_thread_id = None; } + let had_active_view = self.chat_widget.has_active_view(); self.handle_thread_event_now(event); + if !had_active_view + && self.chat_widget.has_active_view() + && self.startup_protected_input_boundary + { + self.chat_widget.pre_draw_tick(); + self.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?; + tui.discard_pending_input_before_interactive_screen()?; + self.startup_pending_protected_request = false; + } if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index df3a77ceb5..cdf84f64ab 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -57,6 +57,16 @@ //! recall is a two-phase handoff: stage the submitted slash text here, then record it after //! `ChatWidget` dispatches the command. //! +//! # 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 +//! 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 +//! and placeholder positions while preserving both composers' contents. The draft remains deferred +//! until protected views close, input is enabled, and required sandbox setup completes. +//! //! # Submission and Prompt Expansion //! //! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is @@ -529,11 +539,14 @@ struct ComposerDraft { #[derive(Clone, Debug, PartialEq)] pub(crate) struct ComposerDraftSnapshot { pub(crate) text: String, + pub(crate) cursor: usize, pub(crate) text_elements: Vec, pub(crate) local_images: Vec, pub(crate) remote_image_urls: Vec, pub(crate) mention_bindings: Vec, pub(crate) pending_pastes: Vec<(String, String)>, + pub(crate) startup_local_history: Vec, + pub(crate) last_composer_activity_at: Option, } const FOOTER_SPACING_HEIGHT: u16 = 0; @@ -1298,6 +1311,22 @@ impl ChatComposer { self.footer.mode = reset_mode_after_activity(self.footer.mode); } + /// Enable Vim while keeping already-active text entry in insert mode. + pub(crate) fn enable_vim_in_insert_mode(&mut self) { + self.set_vim_enabled(/*enabled*/ true); + self.draft.textarea.enter_vim_insert_mode(); + } + + /// Restore draft history transferred from the startup composer. + pub(crate) fn restore_startup_local_history( + &mut self, + startup_local_history: Vec, + ) { + for entry in startup_local_history { + self.history.record_local_submission(entry); + } + } + /// Toggle Vim editing and return the new enabled state. /// /// This is the app-level command target for the configurable Vim toggle @@ -1532,7 +1561,7 @@ impl ChatComposer { } } - fn set_current_cursor(&mut self, cursor: usize) { + pub(crate) fn set_current_cursor(&mut self, cursor: usize) { let visible_cursor = if self.draft.is_bash_mode { cursor.saturating_sub(1) } else { @@ -1716,11 +1745,14 @@ impl ChatComposer { pub(crate) fn draft_snapshot(&self) -> ComposerDraftSnapshot { ComposerDraftSnapshot { text: self.current_text(), + cursor: self.current_cursor(), text_elements: self.text_elements(), local_images: self.local_images(), remote_image_urls: self.remote_image_urls(), mention_bindings: self.mention_bindings(), pending_pastes: self.pending_pastes(), + startup_local_history: self.history.startup_local_history().to_vec(), + last_composer_activity_at: None, } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index a0af332528..5c865f2ec2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -280,15 +280,17 @@ impl ChatComposerHistory { /// Updates persistent history metadata when a new session is configured. /// - /// This clears fetched entries, local entries, navigation cursors, and active search state - /// because offsets only make sense within one history log snapshot. Reusing old offsets after a - /// log-id change would allow a stale async response to hydrate the wrong prompt. + /// Startup-local entries survive the first session configuration because they were recorded + /// before a thread existed. Later configurations clear local history, while every configuration + /// resets fetched entries, navigation cursors, and search state tied to the old history log. pub fn set_metadata(&mut self, thread_id: ThreadId, log_id: u64, entry_count: usize) { - self.thread_id = Some(thread_id); + let had_configured_thread = self.thread_id.replace(thread_id).is_some(); self.persistent_log_id = Some(log_id); self.persistent_entry_count = entry_count; self.fetched_history.clear(); - self.local_history.clear(); + if had_configured_thread { + self.local_history.clear(); + } self.replay_seeded_history.clear(); self.history_cursor = None; self.pending_navigation_direction = None; @@ -296,6 +298,15 @@ impl ChatComposerHistory { self.search = None; } + /// Return draft history recorded before the composer became associated with a thread. + pub(crate) fn startup_local_history(&self) -> &[HistoryEntry] { + if self.thread_id.is_none() { + &self.local_history + } else { + &[] + } + } + /// Records a current-session submission so it can be recalled with full draft metadata. /// /// Empty submissions are ignored, adjacent duplicates are collapsed, and active navigation or @@ -1002,6 +1013,30 @@ mod tests { ); } + #[test] + fn initial_metadata_preserves_startup_history_but_session_changes_clear_it() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut history = ChatComposerHistory::new(); + let startup_entry = HistoryEntry::new("cleared during startup".to_string()); + history.record_local_submission(startup_entry.clone()); + + assert_eq!( + history.startup_local_history(), + std::slice::from_ref(&startup_entry) + ); + + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 0); + + assert!(history.startup_local_history().is_empty()); + assert_eq!(history.navigate_up(&tx), Some(startup_entry)); + + history.record_local_submission(HistoryEntry::new("thread-owned draft".to_string())); + history.set_metadata(ThreadId::new(), /*log_id*/ 2, /*entry_count*/ 0); + + assert_eq!(history.navigate_up(&tx), None); + } + #[test] fn persistent_restore_gates_at_mentions() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 527e510aa7..690024f93a 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -156,6 +156,7 @@ mod scroll_state; mod selection_popup_common; mod selection_row_layout; mod selection_tabs; +mod startup; mod textarea; mod unified_exec_footer; pub(crate) use feedback_view::FeedbackNoteView; @@ -194,6 +195,7 @@ pub(crate) enum CancellationEvent { use crate::bottom_pane::prompt_args::parse_slash_name; pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposerConfig; +pub(crate) use chat_composer::ComposerDraftSnapshot; pub(crate) use chat_composer::InputResult; pub(crate) use chat_composer::QueuedInputAction; pub(crate) use chat_composer_history::HistoryEntry; @@ -267,6 +269,14 @@ pub(crate) struct BottomPaneParams { impl BottomPane { pub fn new(params: BottomPaneParams) -> Self { + Self::new_with_composer_config(params, ChatComposerConfig::default()) + } + + /// Construct a bottom pane with explicitly restricted composer behavior. + pub(crate) fn new_with_composer_config( + params: BottomPaneParams, + composer_config: ChatComposerConfig, + ) -> Self { let BottomPaneParams { app_event_tx, frame_requester, @@ -277,12 +287,13 @@ impl BottomPane { animations_enabled, skills, } = params; - let mut composer = ChatComposer::new( + let mut composer = ChatComposer::new_with_config( has_input_focus, app_event_tx.clone(), enhanced_keys_supported, placeholder_text, disable_paste_burst, + composer_config, ); composer.set_frame_requester(frame_requester.clone()); let keymap = RuntimeKeymap::defaults(); @@ -577,7 +588,7 @@ impl BottomPane { fn record_composer_activity_at(&mut self, now: Instant) { self.last_composer_activity_at = Some(now); - if !self.delayed_approval_requests.is_empty() + if self.has_pending_approval() && let Some(delay) = self.approval_prompt_delay_remaining(now) { self.request_redraw_in(delay); @@ -585,7 +596,7 @@ impl BottomPane { } fn maybe_show_delayed_approval_requests_at(&mut self, now: Instant) { - if self.delayed_approval_requests.is_empty() || !self.view_stack.is_empty() { + if !self.has_pending_approval() || !self.view_stack.is_empty() { return; } if let Some(delay) = self.approval_prompt_delay_remaining(now) { @@ -884,10 +895,6 @@ impl BottomPane { self.composer.cursor() } - pub(crate) fn composer_draft_snapshot(&self) -> chat_composer::ComposerDraftSnapshot { - self.composer.draft_snapshot() - } - #[cfg(test)] pub(crate) fn composer_text_elements(&self) -> Vec { self.composer.text_elements() diff --git a/codex-rs/tui/src/bottom_pane/startup.rs b/codex-rs/tui/src/bottom_pane/startup.rs new file mode 100644 index 0000000000..86b73cb729 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/startup.rs @@ -0,0 +1,113 @@ +//! 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, +//! composer history and cursor state, and the approval-idle timing inherited by the real composer. + +use std::time::Instant; + +use crossterm::event::KeyEvent; + +use super::BottomPane; +use super::ComposerDraftSnapshot; +use super::HistoryEntry; +use crate::key_hint::KeyBindingListExt; + +impl BottomPane { + /// 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; + [ + &composer.submit, + &composer.queue, + &composer.history_search_previous, + &composer.history_search_next, + ] + .into_iter() + .any(|bindings| bindings.is_pressed(key_event)) + || self.composer_is_empty() && composer.toggle_shortcuts.is_pressed(key_event) + } + + /// 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; + [ + &editor.insert_newline, + &editor.move_left, + &editor.move_right, + &editor.move_up, + &editor.move_down, + &editor.move_word_left, + &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)) + } + + /// Enable Vim without interrupting an in-progress startup draft. + pub(crate) fn enable_vim_in_insert_mode(&mut self) { + self.composer.enable_vim_in_insert_mode(); + self.request_redraw(); + } + + /// Apply paste-burst preferences without dropping input buffered before config loading. + pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { + self.disable_paste_burst = disabled; + self.composer.set_disable_paste_burst(disabled); + } + + /// Capture draft content together with the activity timestamp used by delayed approvals. + pub(crate) fn composer_draft_snapshot(&self) -> ComposerDraftSnapshot { + let mut draft = self.composer.draft_snapshot(); + draft.last_composer_activity_at = self.last_composer_activity_at; + draft + } + + /// 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); + self.composer.sync_popups(); + } + + /// Preserve startup-local history and the newest protected-prompt activity timestamp. + pub(crate) fn restore_startup_composer_state( + &mut self, + last_composer_activity_at: Option, + startup_local_history: Vec, + ) { + self.composer + .restore_startup_local_history(startup_local_history); + if let Some(last_composer_activity_at) = last_composer_activity_at { + let last_composer_activity_at = self + .last_composer_activity_at + .map_or(last_composer_activity_at, |current| { + current.max(last_composer_activity_at) + }); + self.record_composer_activity_at(last_composer_activity_at); + } + } + + /// Return whether an approval is waiting to become visible after typing stops. + pub(crate) fn has_pending_approval(&self) -> bool { + !self.delayed_approval_requests.is_empty() + } + + /// Preserve buffered composer input before its owner or input mode changes. + pub(crate) fn flush_composer_paste_burst(&mut self) { + if self.composer.is_in_paste_burst() { + self.composer.set_disable_paste_burst(/*disabled*/ true); + self.composer + .set_disable_paste_burst(self.disable_paste_burst); + } + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9e847e67de..03b51a17cc 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1738,7 +1738,6 @@ impl ChatWidget { self.bottom_pane.pending_thread_approvals() } - #[cfg(test)] pub(crate) fn has_active_view(&self) -> bool { self.bottom_pane.has_active_view() } diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index 101e3ec6bc..28b88f89d1 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -3,10 +3,116 @@ use std::collections::HashSet; use std::collections::VecDeque; +use crate::bottom_pane::ComposerDraftSnapshot; + use super::user_messages::remap_colliding_paste_placeholders; use super::*; impl ChatWidget { + /// Restore the exact draft entered before the fully initialized composer became available. + pub(crate) fn restore_startup_draft(&mut self, draft: ComposerDraftSnapshot) { + let existing_draft = self.bottom_pane.composer_draft_snapshot(); + let existing_cursor = existing_draft.cursor; + let existing_message = UserMessage { + text: existing_draft.text, + text_elements: existing_draft.text_elements, + local_images: existing_draft.local_images, + remote_image_urls: existing_draft.remote_image_urls, + mention_bindings: existing_draft.mention_bindings, + }; + let existing_has_content = + !self.bottom_pane.composer_is_empty() || !existing_draft.pending_pastes.is_empty(); + + let startup_message = UserMessage { + text: draft.text, + text_elements: draft.text_elements, + local_images: draft.local_images, + remote_image_urls: draft.remote_image_urls, + mention_bindings: draft.mention_bindings, + }; + let startup_has_content = !startup_message.text.is_empty() + || !startup_message.local_images.is_empty() + || !startup_message.remote_image_urls.is_empty() + || !draft.pending_pastes.is_empty(); + + let cursor = if existing_has_content && startup_has_content { + let mut used_paste_placeholders = HashSet::new(); + let (existing_message, mut pending_pastes) = remap_colliding_paste_placeholders( + existing_message, + existing_draft.pending_pastes, + &mut used_paste_placeholders, + ); + let startup_offset = existing_message.text.len().saturating_add(1); + let preceding_text_element = startup_message + .text_elements + .iter() + .enumerate() + .take_while(|(_, element)| element.byte_range.end <= draft.cursor) + .last() + .map(|(index, element)| (index, element.byte_range.end)); + let (startup_message, startup_pending_pastes) = remap_colliding_paste_placeholders( + startup_message, + draft.pending_pastes, + &mut used_paste_placeholders, + ); + let cursor_adjustment = preceding_text_element.map_or(0, |(index, original_end)| { + startup_message.text_elements[index] + .byte_range + .end + .saturating_sub(original_end) + }); + pending_pastes.extend(startup_pending_pastes); + self.restore_composer_state(Self::composer_state_from_user_message( + merge_user_messages(vec![existing_message, startup_message]), + pending_pastes, + )); + startup_offset + .saturating_add(draft.cursor) + .saturating_add(cursor_adjustment) + } else if existing_has_content { + existing_cursor + } else { + self.restore_composer_state(Self::composer_state_from_user_message( + startup_message, + draft.pending_pastes, + )); + draft.cursor + }; + self.bottom_pane.set_composer_cursor(cursor); + self.bottom_pane.restore_startup_composer_state( + draft.last_composer_activity_at, + draft.startup_local_history, + ); + if startup_has_content && self.config.tui_vim_mode_default { + self.bottom_pane.enable_vim_in_insert_mode(); + } + } + + /// Includes protected prompts deferred by streaming or the approval idle timer. + pub(crate) fn has_pending_protected_request(&self) -> bool { + self.bottom_pane.has_pending_approval() || self.interrupts.has_pending_prompt() + } + + /// Transfer startup input only after protected views and required sandbox setup finish. + pub(crate) fn restore_startup_draft_when_ready( + &mut self, + pending_draft: &mut Option, + ) { + if self.has_active_view() + || self.has_pending_protected_request() + || !self.bottom_pane.composer_input_enabled() + { + return; + } + #[cfg(any(target_os = "windows", test))] + if self.elevated_windows_sandbox_setup_required() { + return; + } + if let Some(draft) = pending_draft.take() { + self.restore_startup_draft(draft); + } + } + pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) { self.suppress_initial_user_message_submit = suppressed; } diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index d71f7b6ce5..2f664345a6 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -44,6 +44,20 @@ impl InterruptManager { self.queue.is_empty() } + /// Excludes lifecycle events that never claim protected interactive input. + pub(crate) fn has_pending_prompt(&self) -> bool { + self.queue.iter().any(|interrupt| { + matches!( + interrupt, + QueuedInterrupt::ExecApproval(_) + | QueuedInterrupt::ApplyPatchApproval(_) + | QueuedInterrupt::Elicitation { .. } + | QueuedInterrupt::RequestPermissions(_) + | QueuedInterrupt::RequestUserInput(_) + ) + }) + } + pub(crate) fn push_exec_approval(&mut self, ev: ExecApprovalRequestEvent) { self.queue.push_back(QueuedInterrupt::ExecApproval(ev)); } @@ -80,6 +94,9 @@ impl InterruptManager { } pub(crate) fn remove_resolved_prompt(&mut self, request: &ResolvedAppServerRequest) -> bool { + if !self.has_pending_prompt() { + return false; + } let original_len = self.queue.len(); self.queue .retain(|queued| !queued.matches_resolved_prompt(request)); @@ -196,6 +213,7 @@ mod tests { let mut manager = InterruptManager::new(); manager.push_user_input(user_input("call-a", "turn")); manager.push_user_input(user_input("call-b", "turn")); + assert!(manager.has_pending_prompt()); assert!( manager.remove_resolved_prompt(&ResolvedAppServerRequest::UserInput { @@ -204,10 +222,17 @@ mod tests { ); assert_eq!(manager.queue.len(), 1); + assert!(manager.has_pending_prompt()); let Some(QueuedInterrupt::RequestUserInput(remaining)) = manager.queue.front() else { panic!("expected remaining queued user input"); }; assert_eq!(remaining.item_id, "call-a"); + assert!( + manager.remove_resolved_prompt(&ResolvedAppServerRequest::UserInput { + call_id: "call-a".to_string(), + }) + ); + assert!(!manager.has_pending_prompt()); } #[test] @@ -234,6 +259,7 @@ mod tests { fn remove_resolved_prompt_keeps_lifecycle_events() { let mut manager = InterruptManager::new(); manager.push_item_started(command_execution("call")); + assert!(!manager.has_pending_prompt()); assert!( !manager.remove_resolved_prompt(&ResolvedAppServerRequest::ExecApproval { diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 49fa1dd5e5..8728b224b7 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -24,7 +24,9 @@ async fn parent_owned_thread_blocks_all_direct_input_entry_points() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before); + let mut after = chat.bottom_pane.composer_draft_snapshot(); + after.last_composer_activity_at = before.last_composer_activity_at; + assert_eq!(after, before); assert_no_submit_op(&mut op_rx); let rendered = drain_insert_history(&mut rx) .into_iter() @@ -56,7 +58,9 @@ async fn parent_owned_thread_blocks_all_direct_input_entry_points() { .set_composer_text(command.to_string(), Vec::new(), Vec::new()); let before = chat.bottom_pane.composer_draft_snapshot(); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before); + let mut after = chat.bottom_pane.composer_draft_snapshot(); + after.last_composer_activity_at = before.last_composer_activity_at; + assert_eq!(after, before); assert_no_submit_op(&mut op_rx); } @@ -120,6 +124,22 @@ async fn parent_owned_thread_restores_pending_initial_prompt() { assert_eq!(chat.bottom_pane.composer_text(), pending_prompt); assert!(chat.initial_user_message.is_none()); + + let (mut startup_chat, _startup_rx, _startup_op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + startup_chat.bottom_pane.set_composer_text( + "typed during startup".to_string(), + Vec::new(), + Vec::new(), + ); + let mut pending_draft = Some(startup_chat.bottom_pane.composer_draft_snapshot()); + chat.restore_startup_draft_when_ready(&mut pending_draft); + + assert!(pending_draft.is_none()); + assert_eq!( + chat.bottom_pane.composer_text(), + format!("{pending_prompt}\ntyped during startup") + ); assert_no_submit_op(&mut op_rx); } @@ -833,6 +853,267 @@ async fn blocked_image_restore_with_remote_images_keeps_local_placeholder_mappin assert_eq!(chat.remote_image_urls(), remote_image_urls); } +#[tokio::test] +async fn startup_draft_handoff_preserves_cursor_and_large_paste() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let paste = "x".repeat(/*n*/ 1_001); + chat.bottom_pane.handle_paste(paste); + chat.bottom_pane.insert_str(" tail"); + chat.bottom_pane.set_composer_cursor(/*cursor*/ 0); + let startup_draft = chat.bottom_pane.composer_draft_snapshot(); + chat.bottom_pane + .set_composer_text(String::new(), Vec::new(), Vec::new()); + + chat.restore_startup_draft(startup_draft.clone()); + + assert_eq!(chat.bottom_pane.composer_draft_snapshot(), startup_draft); +} + +#[tokio::test] +async fn startup_draft_handoff_merges_existing_prompt_images_and_large_pastes() { + let (mut startup_chat, _startup_rx, _startup_op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + let startup_paste = "x".repeat(/*n*/ 1_001); + startup_chat.bottom_pane.handle_paste(startup_paste.clone()); + startup_chat.bottom_pane.insert_str(" startup draft"); + startup_chat.bottom_pane.set_composer_cursor(/*cursor*/ 0); + let startup_draft = startup_chat.bottom_pane.composer_draft_snapshot(); + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let image_placeholder = "[Image #1]"; + let image_path = PathBuf::from("/tmp/initial-prompt.png"); + chat.bottom_pane.set_composer_text( + format!("{image_placeholder} initial prompt "), + vec![TextElement::new( + (0..image_placeholder.len()).into(), + Some(image_placeholder.to_string()), + )], + vec![image_path.clone()], + ); + let initial_paste = "y".repeat(/*n*/ 1_001); + chat.bottom_pane.handle_paste(initial_paste.clone()); + let existing_draft = chat.bottom_pane.composer_draft_snapshot(); + let existing_placeholder = existing_draft.pending_pastes[0].0.clone(); + + chat.restore_startup_draft(startup_draft); + + let restored = chat.bottom_pane.composer_draft_snapshot(); + assert_eq!( + ( + restored.text, + restored.cursor, + restored.pending_pastes, + restored.local_images, + ), + ( + format!( + "{}\n{existing_placeholder} #2 startup draft", + existing_draft.text + ), + existing_draft.text.len() + 1, + vec![ + (existing_placeholder.clone(), initial_paste), + (format!("{existing_placeholder} #2"), startup_paste), + ], + vec![LocalImageAttachment { + placeholder: image_placeholder.to_string(), + path: image_path, + }], + ) + ); +} + +#[tokio::test] +async fn startup_draft_handoff_rebases_cursor_around_colliding_large_pastes() { + for (cursor_marker, expected_added_bytes) in [("é", 0), (" 中 ", 3), (" tail", 6)] { + let (mut startup_chat, _startup_rx, _startup_op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + startup_chat.bottom_pane.insert_str("é"); + startup_chat + .bottom_pane + .handle_paste("x".repeat(/*n*/ 1_001)); + startup_chat.bottom_pane.insert_str(" 中 "); + startup_chat + .bottom_pane + .handle_paste("y".repeat(/*n*/ 1_002)); + startup_chat.bottom_pane.insert_str(" tail"); + let startup_text = startup_chat.bottom_pane.composer_text(); + let startup_cursor = startup_text + .find(cursor_marker) + .expect("cursor marker should be present") + + cursor_marker.len(); + startup_chat.bottom_pane.set_composer_cursor(startup_cursor); + let startup_draft = startup_chat.bottom_pane.composer_draft_snapshot(); + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.handle_paste("a".repeat(/*n*/ 1_001)); + chat.bottom_pane.insert_str(" "); + chat.bottom_pane.handle_paste("b".repeat(/*n*/ 1_002)); + let existing_text = chat.bottom_pane.composer_text(); + + chat.restore_startup_draft(startup_draft); + + let first_placeholder = "[Pasted Content 1001 chars] #2"; + let second_placeholder = "[Pasted Content 1002 chars] #2"; + let expected_text = + format!("{existing_text}\né{first_placeholder} 中 {second_placeholder} tail"); + let expected_cursor = existing_text.len() + 1 + startup_cursor + expected_added_bytes; + assert_eq!(chat.bottom_pane.composer_text(), expected_text); + assert_eq!(chat.bottom_pane.composer_cursor(), expected_cursor); + + chat.bottom_pane.insert_str("✓"); + let mut expected_edited_text = expected_text; + expected_edited_text.insert(expected_cursor, '✓'); + assert_eq!(chat.bottom_pane.composer_text(), expected_edited_text); + } +} + +#[tokio::test] +async fn startup_draft_handoff_preserves_cleared_history_across_session_configuration() { + for configure_before_restore in [false, true] { + let (mut startup_chat, _startup_rx, _startup_op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + for text in ["first startup draft", "second startup draft"] { + startup_chat + .bottom_pane + .set_composer_text(text.to_string(), Vec::new(), Vec::new()); + startup_chat.bottom_pane.on_ctrl_c(); + } + let paste = "x".repeat(/*n*/ 1_001); + startup_chat.bottom_pane.handle_paste(paste.clone()); + let placeholder = startup_chat.bottom_pane.composer_text(); + startup_chat.bottom_pane.on_ctrl_c(); + let mut pending_draft = Some(startup_chat.bottom_pane.composer_draft_snapshot()); + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + if configure_before_restore { + chat.bottom_pane.set_history_metadata( + ThreadId::new(), + /*log_id*/ 1, + /*entry_count*/ 0, + ); + } + chat.restore_startup_draft_when_ready(&mut pending_draft); + if !configure_before_restore { + chat.bottom_pane.set_history_metadata( + ThreadId::new(), + /*log_id*/ 1, + /*entry_count*/ 0, + ); + } + + assert!(pending_draft.is_none()); + assert!(chat.bottom_pane.composer_is_empty()); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + let restored = chat.bottom_pane.composer_draft_snapshot(); + assert_eq!( + ( + restored.text, + restored.text_elements, + restored.pending_pastes + ), + ( + placeholder.clone(), + vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.clone()), + )], + vec![(placeholder, paste)], + ) + ); + for expected in ["second startup draft", "first startup draft"] { + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), expected); + } + } +} + +#[tokio::test] +async fn startup_draft_handoff_keeps_vim_insert_mode_for_nonempty_drafts() { + for (text, expected) in [("draft", "draftx"), ("", "")] { + let (mut startup_chat, _startup_rx, _startup_op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + startup_chat.bottom_pane.insert_str(text); + let startup_draft = startup_chat.bottom_pane.composer_draft_snapshot(); + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_vim_mode_default = true; + chat.bottom_pane.set_vim_enabled(/*enabled*/ true); + chat.restore_startup_draft(startup_draft); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + chat.bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + + assert_eq!(chat.bottom_pane.composer_text(), expected); + } +} + +#[tokio::test] +async fn startup_draft_handoff_syncs_file_search_with_restored_interior_cursor() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let text = "inspect @src before continuing"; + let cursor = text.find(" before").expect("file token has trailing text"); + chat.bottom_pane + .set_composer_text(text.to_string(), Vec::new(), Vec::new()); + chat.bottom_pane.set_composer_cursor(cursor); + let startup_draft = chat.bottom_pane.composer_draft_snapshot(); + chat.bottom_pane + .set_composer_text(String::new(), Vec::new(), Vec::new()); + while rx.try_recv().is_ok() {} + + chat.restore_startup_draft(startup_draft.clone()); + + assert_eq!(chat.bottom_pane.composer_draft_snapshot(), startup_draft); + assert!(!chat.bottom_pane.no_modal_or_popup_active()); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::StartFileSearch(query) if query == "src")) + ); +} + +#[tokio::test] +async fn startup_draft_file_search_waits_for_protected_view_and_enabled_input() { + let (mut startup_chat, _startup_rx, _startup_op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + startup_chat + .bottom_pane + .set_composer_text("inspect @src".to_string(), Vec::new(), Vec::new()); + let mut pending_draft = Some(startup_chat.bottom_pane.composer_draft_snapshot()); + + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.open_approvals_popup(); + while rx.try_recv().is_ok() {} + + chat.restore_startup_draft_when_ready(&mut pending_draft); + assert!(chat.has_active_view()); + assert!(pending_draft.is_some()); + assert!(rx.try_recv().is_err()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!chat.has_active_view()); + chat.bottom_pane + .set_composer_input_enabled(/*enabled*/ false, /*placeholder*/ None); + chat.restore_startup_draft_when_ready(&mut pending_draft); + assert!(pending_draft.is_some()); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .all(|event| !matches!(event, AppEvent::StartFileSearch(_))) + ); + + chat.bottom_pane + .set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); + chat.restore_startup_draft_when_ready(&mut pending_draft); + + assert!(pending_draft.is_none()); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::StartFileSearch(query) if query == "src")) + ); +} + #[tokio::test] async fn queued_restore_with_remote_images_keeps_local_placeholder_mapping() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 233d3efdb1..15677259d2 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -73,6 +73,7 @@ pub use session_archive_commands::SessionArchiveAction; pub use session_archive_commands::SessionArchiveCommandOptions; pub use session_archive_commands::run_session_archive_command; use std::fs::OpenOptions; +use std::io::IsTerminal; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -171,8 +172,10 @@ mod session_state; mod shimmer; mod skills_helpers; mod slash_command; +mod startup_draft; mod startup_error; mod startup_hooks_review; +mod startup_orchestration; mod status; mod status_indicator_widget; mod streaming; @@ -591,6 +594,16 @@ async fn shutdown_app_server_if_present(app_server: Option) { } } +/// Shut down the startup app server before restoring its terminal and ending its session. +async fn shutdown_startup_session( + app_server: Option, + terminal_restore_guard: &mut TerminalRestoreGuard, +) { + shutdown_app_server_if_present(app_server).await; + terminal_restore_guard.restore_silently(); + session_log::log_session_end(); +} + fn session_target_from_app_server_thread( thread: AppServerThread, ) -> Option { @@ -901,402 +914,36 @@ fn can_reuse_implicit_local_daemon( && !has_non_replayable_launch_overrides } +/// Restore terminal modes before a fatal startup exit bypasses destructor cleanup. +fn restore_terminal_before_fatal_exit() { + if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) { + let _ = tui::restore_after_exit(); + } +} + pub async fn run_main( - mut cli: Cli, + cli: Cli, arg0_paths: Arg0DispatchPaths, loader_overrides: LoaderOverrides, explicit_remote_endpoint: Option, ) -> std::io::Result { - let strict_config = cli.strict_config; - let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox { - ( - Some(SandboxMode::DangerFullAccess), - Some(AskForApproval::Never.to_core()), - ) - } else { - ( - cli.sandbox_mode.map(Into::::into), - cli.approval_policy.map(Into::into), - ) - }; - - cli.shared - .take_auto_review_config_overrides(&mut cli.config_overrides); - - // Map the legacy --search flag to the canonical web_search mode. - if cli.web_search { - cli.config_overrides - .raw_overrides - .push("web_search=\"live\"".to_string()); - } - - // When using `--oss`, let the bootstrapper pick the model (defaulting to - // gpt-oss:20b) and ensure it is present locally. Also, force the built‑in - let raw_overrides = cli.config_overrides.raw_overrides.clone(); - // `oss` model provider. - let overrides_cli = codex_utils_cli::CliConfigOverrides { raw_overrides }; - let cli_kv_overrides = match overrides_cli.parse_overrides() { - // Parse `-c` overrides from the CLI. - Ok(v) => v, - #[allow(clippy::print_stderr)] - Err(e) => { - eprintln!("Error parsing -c overrides: {e}"); - std::process::exit(1); - } - }; - - // we load config.toml here to determine project state. - #[allow(clippy::print_stderr)] - let codex_home = match find_codex_home() { - Ok(codex_home) => codex_home.to_path_buf(), - Err(err) => { - eprintln!("Error finding codex home: {err}"); - std::process::exit(1); - } - }; - - let mut launch_loader_overrides = loader_overrides.clone(); - if let Some(profile_v2) = cli.config_profile_v2.as_ref() { - let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2); - launch_loader_overrides.user_config_path = Some(user_config_path); - launch_loader_overrides.user_config_profile = Some(profile_v2.clone()); - } - let workload_identity_selected = is_workload_identity_selected(); - let reuse_implicit_local_daemon = !workload_identity_selected - && can_reuse_implicit_local_daemon( - &cli_kv_overrides, - &launch_loader_overrides, - strict_config, - cli.bypass_hook_trust, - ); - let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon { - maybe_probe_default_daemon_socket(&codex_home).await - } else { - None - }; - let app_server_target = app_server_target_for_launch( - explicit_remote_endpoint, - default_daemon, - reuse_implicit_local_daemon, - workload_identity_selected, - )?; - let remote_cwd_override = cli - .cwd - .clone() - .filter(|_| app_server_target.uses_remote_workspace()); - - let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( - 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) { - EnvironmentManager::prepare_from_codex_home(&codex_home).await - } else { - EnvironmentManager::prepare_from_env().await - } - .map_err(std::io::Error::other)?; - 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 = load_bootstrap_config_or_exit( - &codex_home, - config_cwd.as_ref(), - cli_kv_overrides.clone(), - loader_overrides.clone(), - strict_config, - CloudConfigBundleLoader::default(), - ) - .await; - let bootstrap_config_toml = &bootstrap_config.config_toml; - let cloud_config_bundle = cloud_config_bundle_for_app_server_target( - &app_server_target, - &bootstrap_config, - &codex_home, - ) - .await?; - - let cwd_override = if app_server_target.uses_remote_workspace() { - None - } else { - cwd.clone() - }; - - let mut manually_selected_oss_provider = None; - let model_provider_override = if cli.oss { - let bootstrap_config_with_cloud_config; - let config_toml_for_oss = if cli.oss_provider.is_none() { - // The first load intentionally skips cloud config so we can read - // auth/base-url settings needed to fetch the bundle. If OSS mode - // needs a default provider from config, reload with the bundle. - bootstrap_config_with_cloud_config = load_bootstrap_config_or_exit( - &codex_home, - config_cwd.as_ref(), - cli_kv_overrides.clone(), - loader_overrides.clone(), - strict_config, - cloud_config_bundle.clone(), - ) - .await; - &bootstrap_config_with_cloud_config.config_toml - } else { - bootstrap_config_toml - }; - - let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), config_toml_for_oss); - - if let Some(provider) = resolved { - Some(provider) - } else { - // No provider configured, prompt the user - let selection = oss_selection::select_oss_provider().await?; - let provider = selection.provider; - if provider == "__CANCELLED__" { - return Err(std::io::Error::other( - "OSS provider selection was cancelled by user", - )); - } - if selection.manually_selected { - manually_selected_oss_provider = Some(provider.clone()); - } - Some(provider) - } - } else { - None - }; - - // When using `--oss`, let the bootstrapper pick the model based on selected provider - let model = if let Some(model) = &cli.model { - Some(model.clone()) - } else if cli.oss { - // Use the provider from model_provider_override - model_provider_override - .as_ref() - .and_then(|provider_id| get_default_model_for_oss_provider(provider_id)) - .map(std::borrow::ToOwned::to_owned) - } else { - None // No model specified, will use the default. - }; - - let additional_dirs = cli.add_dir.clone(); - - let overrides = ConfigOverrides { - model, - approval_policy, - sandbox_mode, - cwd: cwd_override, - model_provider: model_provider_override.clone(), - codex_self_exe: arg0_paths.codex_self_exe.clone(), - codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), - show_raw_agent_reasoning: cli.oss.then_some(true), - bypass_hook_trust: cli.bypass_hook_trust.then_some(true), - additional_writable_roots: additional_dirs, - ..Default::default() - }; - - let config = load_config_or_exit( - cli_kv_overrides.clone(), - overrides.clone(), - loader_overrides.clone(), - cloud_config_bundle.clone(), - strict_config, - ) - .await; - - let cloud_config_bundle = if workload_identity_selected { - cloud_config_bundle - } else { - cloud_config_bundle_loader_for_storage( - app_server_target.auth_config_for_cloud_loader(config.auth_config()), - /*enable_codex_api_key_env*/ false, - ) - .await? - }; - let environment_manager = Arc::new( - prepared_environment_manager - .build(Some(local_runtime_paths), config.http_client_factory()) - .map_err(std::io::Error::other)?, - ); - - remove_legacy_tui_log_file(config.codex_home.as_path()); - - let otel_originator = originator().value; - let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - codex_app_server_client::build_otel_provider( - &config, - env!("CARGO_PKG_VERSION"), - /*service_name_override*/ None, - /*default_analytics_enabled*/ true, - ) - })) { - Ok(Ok(otel)) => otel, - Ok(Err(e)) => { - #[allow(clippy::print_stderr)] - { - eprintln!("Could not create otel exporter: {e}"); - } - None - } - Err(_) => { - #[allow(clippy::print_stderr)] - { - eprintln!("Could not create otel exporter: panicked during initialization"); - } - None - } - }; - if let Some(metrics) = otel.as_ref().and_then(codex_otel::OtelProvider::metrics) { - let _ = codex_otel::record_process_start_once(metrics, otel_originator.as_str()); - let telemetry = - codex_rollout::sqlite_telemetry_recorder(metrics.clone(), otel_originator.as_str()); - let _ = codex_state::install_process_db_telemetry(telemetry); - } - let state_db = init_state_db_for_app_server_target(&config, &app_server_target).await?; - let config_toml_log_dir_configured = config - .config_layer_stack - .effective_config() - .as_table() - .is_some_and(|table| table.contains_key("log_dir")) - || config - .config_layer_stack - .requirements_toml() - .log_dir - .is_some(); - - set_default_client_residency_requirement(config.enforce_residency.value()); - - if let Some(warning) = add_dir_warning_message( - &cli.add_dir, - &config.permissions.effective_permission_profile(), - config.cwd.as_path(), - ) { - #[allow(clippy::print_stderr)] - { - eprintln!("Error adding directories: {warning}"); - std::process::exit(1); - } - } - - if !app_server_target.uses_remote_workspace() && !workload_identity_selected { - #[allow(clippy::print_stderr)] - if let Err(err) = enforce_login_restrictions(&config.auth_config()).await { - eprintln!("{err}"); - std::process::exit(1); - } - } - - let (tui_file_layer, _tui_file_log_guard) = if config_toml_log_dir_configured { - let log_dir = config.log_dir.clone(); - std::fs::create_dir_all(&log_dir)?; - let mut log_file_opts = OpenOptions::new(); - log_file_opts.create(true).append(true); - - // Ensure the file is only readable and writable by the current user. - // Doing the equivalent to `chmod 600` on Windows is quite a bit more - // code and requires the Windows API crates. - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - log_file_opts.mode(0o600); - } - - let log_file = log_file_opts.open(log_dir.join(TUI_LOG_FILE_NAME))?; - let (non_blocking, guard) = non_blocking(log_file); - let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { - EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info") - }); - let file_layer = tracing_subscriber::fmt::layer() - .with_writer(non_blocking) - .with_target(true) - .with_ansi(false) - .with_span_events( - tracing_subscriber::fmt::format::FmtSpan::NEW - | tracing_subscriber::fmt::format::FmtSpan::CLOSE, - ) - .with_filter(env_filter); - (Some(file_layer), Some(guard)) - } else { - (None, None) - }; - - let feedback = codex_feedback::CodexFeedback::new(); - let feedback_layer = feedback.logger_layer(); - let feedback_metadata_layer = feedback.metadata_layer(); - - if cli.oss && model_provider_override.is_some() { - // We're in the oss section, so provider_id should be Some - // Let's handle None case gracefully though just in case - let provider_id = match model_provider_override.as_ref() { - Some(id) => id, - None => { - error!("OSS provider unexpectedly not set when oss flag is used"); - return Err(std::io::Error::other( - "OSS provider not set but oss flag was used", - )); - } - }; - ensure_oss_provider_ready(provider_id, &config).await?; - } - - let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer()); - - let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); - - let log_db = state_db.clone().map(log_db::start); - let log_db_layer = log_db - .clone() - .map(|layer| layer.with_filter(log_db::default_filter())); - - let _ = tracing_subscriber::registry() - .with(tui_file_layer) - .with(feedback_layer) - .with(feedback_metadata_layer) - .with(log_db_layer) - .with(otel_logger_layer) - .with(otel_tracing_layer) - .try_init(); - - let app_result = run_ratatui_app( + match startup_orchestration::run_main_inner( cli, arg0_paths, loader_overrides, - strict_config, - app_server_target, - remote_cwd_override, - config, - manually_selected_oss_provider, - overrides, - cli_kv_overrides, - cloud_config_bundle, - feedback, - log_db, - state_db, - environment_manager, + explicit_remote_endpoint, ) .await - .map_err(|err| std::io::Error::other(err.to_string())); - - if let Some(otel) = otel - && let Err(err) = otel - .shutdown_with_timeout(INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT) - .await { - warn!(error = %err, "failed to finish interactive telemetry shutdown"); + Err(err) if startup_draft::StartupCancelled::matches(&err) => Ok(AppExitInfo { + token_usage: TokenUsage::default(), + thread_id: None, + resume_hint: None, + update_action: None, + exit_reason: ExitReason::UserRequested, + }), + result => result, } - - app_result } #[allow(clippy::too_many_arguments)] @@ -1316,6 +963,7 @@ async fn run_ratatui_app( log_db: Option, state_db: Option, environment_manager: Arc, + startup_draft: startup_draft::StartupDraft, ) -> color_eyre::Result { let uses_remote_workspace = app_server_target.uses_remote_workspace(); let workload_identity_selected = is_workload_identity_selected(); @@ -1329,18 +977,11 @@ async fn run_ratatui_app( // (including backtraces) after we restore the terminal. let prev_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { + let _ = tui::restore_after_exit(); tracing::error!("panic: {info}"); prev_hook(info); })); - let mut initialized_terminal = tui::init()?; - initialized_terminal.terminal.clear()?; - - let mut tui = Tui::new( - initialized_terminal.terminal, - initialized_terminal.enhanced_keys_supported, - initialized_terminal.stderr_guard, - ); - let mut terminal_restore_guard = TerminalRestoreGuard::new(); + let (mut tui, mut terminal_restore_guard, mut startup_draft) = startup_draft.into_parts(); #[cfg(not(debug_assertions))] { @@ -1348,6 +989,7 @@ async fn run_ratatui_app( let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty()); if !skip_update_prompt { + startup_draft.flush_pending_events(&mut tui).await?; match update_prompt::run_update_prompt_if_needed(&mut tui, &initial_config).await? { UpdatePromptOutcome::Continue => {} UpdatePromptOutcome::RunUpdate(action) => { @@ -1367,42 +1009,66 @@ async fn run_ratatui_app( // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&initial_config); - let app_server_session = match start_app_server( - &app_server_target, - arg0_paths.clone(), - initial_config.clone(), - cli_kv_overrides.clone(), - loader_overrides.clone(), - strict_config, - cloud_config_bundle.clone(), - feedback.clone(), - log_db.clone(), - state_db.clone(), - environment_manager.clone(), - ) - .await - { - Ok(app_server) => AppServerSession::new(app_server, app_server_target.thread_params_mode()) - .with_startup_config(&initial_config), - Err(err) => { + let startup_app_server = startup_draft + .run_until( + &mut tui, + start_app_server( + &app_server_target, + arg0_paths.clone(), + initial_config.clone(), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), + feedback.clone(), + log_db.clone(), + state_db.clone(), + environment_manager.clone(), + ), + ) + .await; + let app_server_session = match startup_app_server { + Ok(Ok(app_server)) => { + AppServerSession::new(app_server, app_server_target.thread_params_mode()) + .with_startup_config(&initial_config) + } + Ok(Err(err)) => { terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Err(err); } + Err(err) => { + terminal_restore_guard.restore_silently(); + session_log::log_session_end(); + return Err(err.into()); + } } .with_remote_cwd_override(remote_cwd_override.clone()); - if let Some(provider) = manually_selected_oss_provider.as_deref() - && let Err(err) = config_update::write_config_batch( - app_server_session.request_handle(), - vec![config_update::build_oss_provider_edit(provider)], - ) - .await - { - warn!( - %err, - provider, - "Failed to persist selected OSS provider preference" - ); + if let Some(provider) = manually_selected_oss_provider.as_deref() { + match startup_draft + .run_until( + &mut tui, + config_update::write_config_batch( + app_server_session.request_handle(), + vec![config_update::build_oss_provider_edit(provider)], + ), + ) + .await + { + Ok(Ok(_)) => {} + Ok(Err(err)) => { + warn!( + %err, + provider, + "Failed to persist selected OSS provider preference" + ); + } + Err(err) => { + shutdown_startup_session(Some(app_server_session), &mut terminal_restore_guard) + .await; + return Err(err.into()); + } + } } let mut app_server = Some(app_server_session); @@ -1413,10 +1079,26 @@ async fn run_ratatui_app( let login_status = if workload_identity_selected { LoginStatus::AuthMode(AuthMode::Chatgpt) } else if initial_config.model_provider.requires_openai_auth { - let Some(app_server) = app_server.as_mut() else { + let Some(active_app_server) = app_server.as_mut() else { unreachable!("app server should exist when auth is required"); }; - get_login_status(app_server, &initial_config).await? + let login_status = startup_draft + .run_until( + &mut tui, + get_login_status(active_app_server, &initial_config), + ) + .await; + match login_status { + Ok(Ok(login_status)) => login_status, + Ok(Err(err)) => { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err); + } + Err(err) => { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } + } } else { LoginStatus::NotAuthenticated }; @@ -1424,6 +1106,10 @@ async fn run_ratatui_app( should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { + if let Err(err) = startup_draft.flush_pending_events(&mut tui).await { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } let show_login_screen = should_show_login_screen(login_status, &initial_config); let onboarding_result = run_onboarding_app( OnboardingScreenArgs { @@ -1442,11 +1128,16 @@ async fn run_ratatui_app( }, &mut tui, ) - .await?; + .await; + let onboarding_result = match onboarding_result { + Ok(onboarding_result) => onboarding_result, + Err(err) => { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err); + } + }; if onboarding_result.should_exit { - shutdown_app_server_if_present(app_server.take()).await; - terminal_restore_guard.restore_silently(); - session_log::log_session_end(); + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; let _ = tui.terminal.clear(); return Ok(AppExitInfo { token_usage: crate::token_usage::TokenUsage::default(), @@ -1460,52 +1151,69 @@ async fn run_ratatui_app( { trust_decision_was_made = onboarding_result.directory_trust_persisted; } - // If this onboarding run included the login step, always refresh the cloud config bundle - // and rebuild config. This avoids missing newly available cloud-managed policy due to login - // status detection edge cases. - if show_login_screen && !uses_remote_workspace && !workload_identity_selected { - cloud_config_bundle = cloud_config_bundle_loader_for_storage( - initial_config.auth_config(), - /*enable_codex_api_key_env*/ false, - ) - .await?; - } + let reloaded_config = startup_draft + .run_until(&mut tui, async { + // If this onboarding run included the login step, always refresh the cloud config + // bundle and rebuild config. This avoids missing newly available cloud-managed + // policy due to login status detection edge cases. + if show_login_screen && !uses_remote_workspace && !workload_identity_selected { + cloud_config_bundle = cloud_config_bundle_loader_for_storage( + initial_config.auth_config(), + /*enable_codex_api_key_env*/ false, + ) + .await?; + } - // If the user made an explicit trust decision, or we showed the login flow, reload config - // so current process state reflects persisted trust/auth changes. - if onboarding_result.directory_trust_persisted - || (show_login_screen && !uses_remote_workspace) - { - load_config_or_exit( - cli_kv_overrides.clone(), - overrides.clone(), - loader_overrides.clone(), - cloud_config_bundle.clone(), - strict_config, - ) - .await - } else { - initial_config + // Reload config when persisted trust or auth changes alter the current process. + Ok::<_, std::io::Error>( + if onboarding_result.directory_trust_persisted + || (show_login_screen && !uses_remote_workspace) + { + load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + loader_overrides.clone(), + cloud_config_bundle.clone(), + strict_config, + ) + .await + } else { + initial_config + }, + ) + }) + .await; + match reloaded_config { + Ok(Ok(config)) => config, + Ok(Err(err)) | Err(err) => { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } } } else { initial_config }; + startup_draft.apply_config(&config); - let mut missing_session_exit = |id_str: &str, action: &str| { - error!("Error finding conversation path: {id_str}"); - terminal_restore_guard.restore_silently(); - session_log::log_session_end(); - let _ = tui.terminal.clear(); - Ok(AppExitInfo { - token_usage: crate::token_usage::TokenUsage::default(), - thread_id: None, - resume_hint: None, - update_action: None, - exit_reason: ExitReason::Fatal(format!( - "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." - )), - }) - }; + let missing_session_exit = + |id_str: &str, + action: &str, + tui: &mut Tui, + terminal_restore_guard: &mut TerminalRestoreGuard| { + error!("Error finding conversation path: {id_str}"); + terminal_restore_guard.restore_silently(); + session_log::log_session_end(); + let _ = tui.terminal.clear(); + Ok(AppExitInfo { + token_usage: crate::token_usage::TokenUsage::default(), + thread_id: None, + resume_hint: None, + update_action: None, + exit_reason: ExitReason::Fatal(format!( + "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." + )), + }) + }; let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some(); let session_selection = if use_fork { @@ -1513,12 +1221,29 @@ async fn run_ratatui_app( let Some(startup_app_server) = app_server.as_mut() else { unreachable!("app server should be initialized for --fork "); }; - match lookup_session_target_with_app_server(startup_app_server, &config, id_str).await? - { + let lookup = startup_draft + .run_until( + &mut tui, + lookup_session_target_with_app_server(startup_app_server, &config, id_str), + ) + .await; + let target_session = match lookup { + Ok(result) => result?, + Err(err) => { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } + }; + match target_session { Some(target_session) => resume_picker::SessionSelection::Fork(target_session), None => { shutdown_app_server_if_present(app_server.take()).await; - return missing_session_exit(id_str, "fork"); + return missing_session_exit( + id_str, + "fork", + &mut tui, + &mut terminal_restore_guard, + ); } } } else if cli.fork_last { @@ -1528,18 +1253,36 @@ async fn run_ratatui_app( &config, cli.fork_show_all, ); - let Some(app_server) = app_server.as_mut() else { + let Some(startup_app_server) = app_server.as_mut() else { unreachable!("app server should be initialized for --fork --last"); }; - match lookup_latest_session_target_with_app_server( - app_server, &config, filter_cwd, /*include_non_interactive*/ false, - ) - .await? - { + let lookup = startup_draft + .run_until( + &mut tui, + lookup_latest_session_target_with_app_server( + startup_app_server, + &config, + filter_cwd, + /*include_non_interactive*/ false, + ), + ) + .await; + let target_session = match lookup { + Ok(result) => result?, + Err(err) => { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } + }; + match target_session { Some(target_session) => resume_picker::SessionSelection::Fork(target_session), None => resume_picker::SessionSelection::StartFresh, } } else if cli.fork_picker { + if let Err(err) = startup_draft.flush_pending_events(&mut tui).await { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } let Some(app_server) = app_server.take() else { unreachable!("app server should be initialized for --fork picker"); }; @@ -1571,11 +1314,29 @@ async fn run_ratatui_app( let Some(startup_app_server) = app_server.as_mut() else { unreachable!("app server should be initialized for --resume "); }; - match lookup_session_target_with_app_server(startup_app_server, &config, id_str).await? { + let lookup = startup_draft + .run_until( + &mut tui, + lookup_session_target_with_app_server(startup_app_server, &config, id_str), + ) + .await; + let target_session = match lookup { + Ok(result) => result?, + Err(err) => { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } + }; + match target_session { Some(target_session) => resume_picker::SessionSelection::Resume(target_session), None => { shutdown_app_server_if_present(app_server.take()).await; - return missing_session_exit(id_str, "resume"); + return missing_session_exit( + id_str, + "resume", + &mut tui, + &mut terminal_restore_guard, + ); } } } else if cli.resume_last { @@ -1585,21 +1346,36 @@ async fn run_ratatui_app( &config, cli.resume_show_all, ); - let Some(app_server) = app_server.as_mut() else { + let Some(startup_app_server) = app_server.as_mut() else { unreachable!("app server should be initialized for --resume --last"); }; - match lookup_latest_session_target_with_app_server( - app_server, - &config, - filter_cwd, - cli.resume_include_non_interactive, - ) - .await? - { + let lookup = startup_draft + .run_until( + &mut tui, + lookup_latest_session_target_with_app_server( + startup_app_server, + &config, + filter_cwd, + cli.resume_include_non_interactive, + ), + ) + .await; + let target_session = match lookup { + Ok(result) => result?, + Err(err) => { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } + }; + match target_session { Some(target_session) => resume_picker::SessionSelection::Resume(target_session), None => resume_picker::SessionSelection::StartFresh, } } else if cli.resume_picker { + if let Err(err) = startup_draft.flush_pending_events(&mut tui).await { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } let Some(app_server) = app_server.take() else { unreachable!("app server should be initialized for --resume picker"); }; @@ -1629,6 +1405,15 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + if matches!( + &session_selection, + resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) + ) && let Err(err) = startup_draft.flush_pending_events(&mut tui).await + { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } + let current_cwd = config.cwd.clone(); let fallback_cwd = match resolve_startup_resume_or_fork_cwd( &mut tui, @@ -1660,35 +1445,58 @@ async fn run_ratatui_app( } }; + if (cli.resume_picker || cli.fork_picker) + && let Err(err) = startup_draft.show(&mut tui) + { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } + let picker_cancelled_without_selection = matches!( session_selection, resume_picker::SessionSelection::StartFresh ) && (cli.resume_picker || cli.fork_picker); - let mut config = match &session_selection { + let reloaded_config = match &session_selection { resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => { - load_config_or_exit_with_fallback_cwd( - cli_kv_overrides.clone(), - overrides.clone(), - loader_overrides.clone(), - cloud_config_bundle.clone(), - strict_config, - fallback_cwd, - ) - .await + startup_draft + .run_until( + &mut tui, + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + loader_overrides.clone(), + cloud_config_bundle.clone(), + strict_config, + fallback_cwd, + ), + ) + .await } resume_picker::SessionSelection::StartFresh if picker_cancelled_without_selection => { - load_config_or_exit( - cli_kv_overrides.clone(), - overrides.clone(), - loader_overrides.clone(), - cloud_config_bundle.clone(), - strict_config, - ) - .await + startup_draft + .run_until( + &mut tui, + load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + loader_overrides.clone(), + cloud_config_bundle.clone(), + strict_config, + ), + ) + .await } - _ => config, + _ => Ok(config), }; + let mut config = match reloaded_config { + Ok(config) => config, + Err(err) => { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; + return Err(err.into()); + } + }; + startup_draft.apply_config(&config); // Configure syntax highlighting theme from the final config — onboarding // and resume/fork can both reload config with a different tui_theme, so @@ -1733,31 +1541,40 @@ async fn run_ratatui_app( tui.set_alt_screen_enabled(use_alt_screen); let mut app_server = match app_server { Some(app_server) => app_server, - None => match start_app_server( - &app_server_target, - arg0_paths, - config.clone(), - cli_kv_overrides.clone(), - loader_overrides.clone(), - strict_config, - cloud_config_bundle.clone(), - feedback.clone(), - log_db.clone(), - state_db.clone(), - environment_manager.clone(), - ) - .await + None => match startup_draft + .run_until( + &mut tui, + start_app_server( + &app_server_target, + arg0_paths, + config.clone(), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), + feedback.clone(), + log_db.clone(), + state_db.clone(), + environment_manager.clone(), + ), + ) + .await { - Ok(app_server) => { + Ok(Ok(app_server)) => { AppServerSession::new(app_server, app_server_target.thread_params_mode()) .with_startup_config(&config) .with_remote_cwd_override(remote_cwd_override.clone()) } - Err(err) => { + Ok(Err(err)) => { terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Err(err); } + Err(err) => { + terminal_restore_guard.restore_silently(); + session_log::log_session_end(); + return Err(err.into()); + } }, }; @@ -1772,23 +1589,48 @@ async fn run_ratatui_app( let hooks_request_handle = app_server.request_handle(); let hooks_cwd = config.cwd.to_path_buf(); let startup_prefetch_started_at = Instant::now(); - let (startup_bootstrap, startup_hooks_entry) = tokio::join!( - app_server.bootstrap(&config), - load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd), - ); - let startup_bootstrap = Some(startup_bootstrap?); + let startup_prefetch = startup_draft + .run_until(&mut tui, async { + tokio::join!( + app_server.bootstrap(&config), + load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd), + ) + }) + .await; + let (startup_bootstrap, startup_hooks_entry) = match startup_prefetch { + Ok(startup_prefetch) => startup_prefetch, + Err(err) => { + shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await; + return Err(err.into()); + } + }; + if let Err(err) = startup_draft.flush_pending_events(&mut tui).await { + shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await; + return Err(err.into()); + } + let startup_bootstrap = match startup_bootstrap { + Ok(startup_bootstrap) => Some(startup_bootstrap), + Err(err) => { + shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await; + return Err(err); + } + }; let startup_elapsed_before_app = startup_prefetch_started_at.elapsed(); - let startup_hooks_browser = match maybe_run_startup_hooks_review( + let startup_hooks_review = maybe_run_startup_hooks_review( &mut app_server, &mut tui, &config, bypass_hook_trust_for_startup_review, startup_hooks_entry, ) - .await? - { - StartupHooksReviewOutcome::Continue => None, - StartupHooksReviewOutcome::OpenHooksBrowser(data) => Some(data), + .await; + let startup_hooks_browser = match startup_hooks_review { + Err(err) => { + shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await; + return Err(err); + } + Ok(StartupHooksReviewOutcome::Continue) => None, + Ok(StartupHooksReviewOutcome::OpenHooksBrowser(data)) => Some(data), }; let app_result = App::run( @@ -1812,6 +1654,7 @@ async fn run_ratatui_app( startup_elapsed_before_app, startup_bootstrap, startup_hooks_browser, + startup_draft, ) .await; @@ -1946,6 +1789,7 @@ async fn load_config_or_exit_with_fallback_cwd( { Ok(config) => config, Err(err) => { + restore_terminal_before_fatal_exit(); eprintln!("Error loading configuration: {err}"); std::process::exit(1); } @@ -1975,6 +1819,7 @@ async fn load_bootstrap_config_or_exit( { Ok(config_toml) => config_toml, Err(err) => { + restore_terminal_before_fatal_exit(); let config_error = err .get_ref() .and_then(|err| err.downcast_ref::()) diff --git a/codex-rs/tui/src/oss_selection.rs b/codex-rs/tui/src/oss_selection.rs index 844d40f881..7dd2b4d32d 100644 --- a/codex-rs/tui/src/oss_selection.rs +++ b/codex-rs/tui/src/oss_selection.rs @@ -49,7 +49,7 @@ struct ProviderOption { } #[derive(Clone)] -enum ProviderStatus { +pub(crate) enum ProviderStatus { Running, NotRunning, Unknown, @@ -318,7 +318,16 @@ pub(crate) struct OssProviderSelection { pub(crate) manually_selected: bool, } -pub async fn select_oss_provider() -> io::Result { +pub(crate) enum OssProviderDetection { + AutoSelected(OssProviderSelection), + NeedsSelection { + lmstudio_status: ProviderStatus, + ollama_status: ProviderStatus, + }, +} + +/// Probe local providers without suspending the interactive startup composer. +pub(crate) async fn detect_oss_provider() -> OssProviderDetection { // These probes intentionally bypass proxy discovery because both targets are // hardcoded plaintext loopback endpoints. Preserve the legacy custom-CA fallback so an // invalid inherited certificate bundle cannot prevent best-effort provider detection. @@ -333,23 +342,30 @@ pub async fn select_oss_provider() -> io::Result { match (&lmstudio_status, &ollama_status) { (ProviderStatus::Running, ProviderStatus::NotRunning) => { let provider = LMSTUDIO_OSS_PROVIDER_ID.to_string(); - return Ok(OssProviderSelection { + OssProviderDetection::AutoSelected(OssProviderSelection { provider, manually_selected: false, - }); + }) } (ProviderStatus::NotRunning, ProviderStatus::Running) => { let provider = OLLAMA_OSS_PROVIDER_ID.to_string(); - return Ok(OssProviderSelection { + OssProviderDetection::AutoSelected(OssProviderSelection { provider, manually_selected: false, - }); - } - _ => { - // Both running or both not running - show UI + }) } + _ => OssProviderDetection::NeedsSelection { + lmstudio_status, + ollama_status, + }, } +} +/// Run the actionable provider picker after provider discovery requires a user decision. +pub(crate) async fn select_oss_provider( + lmstudio_status: ProviderStatus, + ollama_status: ProviderStatus, +) -> io::Result { let mut widget = OssSelectionWidget::new(lmstudio_status, ollama_status)?; enable_raw_mode()?; diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_full_frames.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_full_frames.snap new file mode 100644 index 0000000000..7ee3bae3a3 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__startup_draft_full_frames.snap @@ -0,0 +1,44 @@ +--- +source: tui/src/startup_draft_tests.rs +expression: "snapshots.join(\"\\n---\\n\")" +--- +empty (48 columns): +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› Ask Codex to do anything + + ? for shortcuts +--- +multiline (48 columns): +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› first startup line + second startup line + + +--- +narrow (18 columns): +╭────────────────╮ +│ >_ OpenAI Cod… │ +│ │ +│ model: lo… │ +│ directory: …ng │ +╰────────────────╯ + + +› first startup + line + second startup + line diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs new file mode 100644 index 0000000000..399634e98a --- /dev/null +++ b/codex-rs/tui/src/startup_draft.rs @@ -0,0 +1,461 @@ +//! Display an editable, non-submitting composer while startup work continues. + +use std::future::Future; +use std::io; +use std::path::PathBuf; +use std::pin::Pin; +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 ratatui::layout::Size; +use ratatui::style::Modifier; +use ratatui::style::Style; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::mpsc::unbounded_channel; +use tokio_stream::Stream; +use tokio_stream::StreamExt; + +use crate::TerminalRestoreGuard; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPane; +use crate::bottom_pane::BottomPaneParams; +use crate::bottom_pane::ChatComposer; +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; +use crate::render::renderable::FlexRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableExt; +use crate::render::renderable::RenderableItem; +use crate::tui; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use crate::version::CODEX_CLI_VERSION; + +const STARTUP_EVENT_BATCH_SIZE: usize = 64; +const STARTUP_PASTE_NEWLINE_TIMEOUT: Duration = Duration::from_millis(120); + +/// Identifies the first interactive surface expected for the current invocation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum StartupDraftInitialScreen { + Composer, + SessionPicker, +} + +/// Marks intentional startup cancellation so unrelated I/O interrupts remain errors. +#[derive(Debug, thiserror::Error)] +#[error("startup cancelled")] +pub(crate) struct StartupCancelled; + +impl StartupCancelled { + /// Distinguish intentional user cancellation from unrelated interrupted I/O. + pub(crate) fn matches(error: &io::Error) -> bool { + error.kind() == io::ErrorKind::Interrupted + && error.get_ref().is_some_and(::is::) + } +} + +/// Owns a real composer without allowing prompts, commands, or image access before startup. +pub(crate) struct StartupDraft { + tui: Tui, + terminal_restore_guard: TerminalRestoreGuard, + pump: StartupDraftPump, +} + +/// Keeps the existing terminal responsive without owning it or permitting startup submission. +pub(crate) struct StartupDraftPump { + header: Box, + bottom_pane: BottomPane, + events: Pin + Send>>, + app_event_rx: UnboundedReceiver, + initial_screen: StartupDraftInitialScreen, + pending_paste_newline: Option<(Instant, String)>, +} + +impl StartupDraft { + /// Initialize the terminal without showing a composer before an expected session picker. + pub(crate) fn new(initial_screen: StartupDraftInitialScreen) -> io::Result { + let mut initialized_terminal = tui::init()?; + let terminal_restore_guard = TerminalRestoreGuard::new(); + initialized_terminal.terminal.clear()?; + + let tui = Tui::new( + initialized_terminal.terminal, + initialized_terminal.enhanced_keys_supported, + initialized_terminal.stderr_guard, + ); + let (app_event_tx, app_event_rx) = unbounded_channel(); + let bottom_pane = startup_draft_bottom_pane( + AppEventSender::new(app_event_tx), + tui.frame_requester(), + tui.enhanced_keys_supported(), + ); + let events = tui.event_stream(); + let mut draft = Self { + tui, + terminal_restore_guard, + pump: StartupDraftPump { + header: startup_session_header(/*config*/ None), + bottom_pane, + events, + app_event_rx, + initial_screen, + pending_paste_newline: None, + }, + }; + if initial_screen == StartupDraftInitialScreen::Composer { + draft.pump.show(&mut draft.tui)?; + } + Ok(draft) + } + + /// Accept draft edits on the current task until an existing startup operation completes. + pub(crate) async fn run_until(&mut self, future: F) -> io::Result + where + F: Future, + { + self.pump.run_until(&mut self.tui, future).await + } + + /// Capture queued draft edits before lending the terminal to another input owner. + pub(crate) async fn flush_pending_events(&mut self) -> io::Result<()> { + self.pump.flush_pending_events(&mut self.tui).await + } + + /// Apply the loaded editing preferences without enabling startup actions or submission. + pub(crate) fn apply_config(&mut self, config: &Config) { + self.pump.apply_config(config); + } + + /// Lend the original terminal to an existing interactive startup screen. + pub(crate) fn tui_mut(&mut self) -> &mut Tui { + &mut self.tui + } + + /// Transfer terminal ownership while keeping the same draft pump available to later startup. + pub(crate) fn into_parts(self) -> (Tui, TerminalRestoreGuard, StartupDraftPump) { + (self.tui, self.terminal_restore_guard, self.pump) + } +} + +impl StartupDraftPump { + /// Refresh the session header and safe editor shortcuts without enabling modal editing. + pub(crate) fn apply_config(&mut self, config: &Config) { + self.header = startup_session_header(Some(config)); + self.bottom_pane + .set_disable_paste_burst(config.disable_paste_burst); + self.bottom_pane.request_redraw(); + if let Ok(keymap) = RuntimeKeymap::from_config(&config.tui_keymap) { + self.bottom_pane.set_keymap_bindings(&keymap); + } + } + + /// Poll one existing startup future alongside the original terminal input stream. + pub(crate) async fn run_until(&mut self, tui: &mut Tui, future: F) -> io::Result + where + F: Future, + { + if self.initial_screen == StartupDraftInitialScreen::Composer { + self.draw(tui, tui.terminal.last_known_screen_size)?; + } + tokio::pin!(future); + loop { + tokio::select! { + output = &mut future => return Ok(output), + event = self.events.next() => { + let Some(event) = event else { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "terminal input stream closed during startup", + )); + }; + self.handle_event(tui, event)?; + } + } + } + } + + /// Preserve pending draft edits while continuing to reject startup actions and submission. + pub(crate) async fn flush_pending_events(&mut self, tui: &mut Tui) -> io::Result<()> { + loop { + for _ in 0..STARTUP_EVENT_BATCH_SIZE { + let Poll::Ready(Some(event)) = std::future::poll_fn(|context| { + Poll::Ready(self.events.as_mut().poll_next(context)) + }) + .await + else { + self.bottom_pane.flush_composer_paste_burst(); + return Ok(()); + }; + self.handle_event(tui, event)?; + } + + tokio::task::yield_now().await; + } + } + + /// Resolve an ambiguous paste newline before its provisional input owner disappears. + pub(crate) async fn flush_pending_paste_newline(&mut self, tui: &mut Tui) -> io::Result<()> { + while let Some((started_at, _)) = &self.pending_paste_newline { + let Some(remaining) = STARTUP_PASTE_NEWLINE_TIMEOUT.checked_sub(started_at.elapsed()) + else { + self.pending_paste_newline = None; + break; + }; + let Ok(Some(event)) = tokio::time::timeout(remaining, self.events.next()).await else { + self.pending_paste_newline = None; + break; + }; + self.handle_event(tui, event)?; + } + self.bottom_pane.flush_composer_paste_burst(); + Ok(()) + } + + /// Preserve the editable draft, its cursor, and any pending large-paste placeholders. + pub(crate) fn into_draft(mut self) -> ComposerDraftSnapshot { + self.bottom_pane.flush_composer_paste_burst(); + self.bottom_pane.composer_draft_snapshot() + } + + /// Reveal the editable composer once a requested session picker has finished. + pub(crate) fn show(&mut self, tui: &mut Tui) -> io::Result<()> { + self.initial_screen = StartupDraftInitialScreen::Composer; + 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 => { + self.pending_paste_newline = Some((started_at, newlines)); + if self.initial_screen == StartupDraftInitialScreen::Composer { + self.draw(tui, screen_size)?; + } + 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::SessionPicker + && !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())); + } + } + handle_startup_draft_key(&mut self.bottom_pane, key).inspect_err(|error| { + if StartupCancelled::matches(error) + && let Err(clear_error) = tui.terminal.clear() + { + tracing::warn!( + error = %clear_error, + "failed to clear the cancelled startup composer" + ); + } + })?; + } + 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 => {} + } + 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.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); + let desired_height = 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); + if let Some((x, y)) = renderable.cursor_pos(area) { + frame.set_cursor_style(renderable.cursor_style(area)); + frame.set_cursor_position((x, y)); + } + }) + } +} + +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( + || PathBuf::from("loading"), + |config| config.cwd.to_path_buf(), + ); + Box::new( + history_cell::SessionHeaderHistoryCell::new_with_style( + "loading".to_string(), + placeholder_style, + /*reasoning_effort*/ None, + /*show_fast_status*/ false, + directory, + CODEX_CLI_VERSION, + ) + .with_yolo_mode(config.is_some_and(history_cell::is_yolo_mode)), + ) +} + +fn startup_draft_renderable<'a>( + header: &'a dyn Renderable, + bottom_pane: &'a BottomPane, +) -> RenderableItem<'a> { + let mut renderable = FlexRenderable::new(); + renderable.push(/*flex*/ 1, RenderableItem::Borrowed(header)); + renderable.push( + /*flex*/ 0, + bottom_pane + .as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0) + .inset(Insets::tlbr( + /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, + )), + ); + RenderableItem::Owned(Box::new(renderable)) +} + +fn startup_draft_bottom_pane( + app_event_tx: AppEventSender, + frame_requester: FrameRequester, + enhanced_keys_supported: bool, +) -> BottomPane { + let mut bottom_pane = BottomPane::new_with_composer_config( + BottomPaneParams { + app_event_tx, + frame_requester, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: None, + }, + ChatComposerConfig::plain_text(), + ); + bottom_pane.set_context_window_pending(/*pending*/ true); + bottom_pane +} + +#[cfg(test)] +#[path = "startup_draft_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/startup_draft_tests.rs b/codex-rs/tui/src/startup_draft_tests.rs new file mode 100644 index 0000000000..828ef8572d --- /dev/null +++ b/codex-rs/tui/src/startup_draft_tests.rs @@ -0,0 +1,595 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use tokio::sync::mpsc::unbounded_channel; + +use super::StartupDraftInitialScreen; +use super::StartupDraftPump; +use super::handle_startup_draft_key; +use super::startup_draft_bottom_pane; +use super::startup_draft_renderable; +use super::startup_session_header; +use crate::app_event_sender::AppEventSender; +use crate::legacy_core::config::ConfigBuilder; +use crate::render::renderable::Renderable; +use crate::tui::FrameRequester; +use crate::tui::TuiEvent; + +fn startup_test_pump(events: I) -> StartupDraftPump +where + I: Iterator + Send + 'static, +{ + let (tx, rx) = unbounded_channel(); + StartupDraftPump { + header: startup_session_header(/*config*/ None), + bottom_pane: startup_draft_bottom_pane( + AppEventSender::new(tx), + FrameRequester::test_dummy(), + /*enhanced_keys_supported*/ false, + ), + events: Box::pin(futures::stream::iter(events)), + app_event_rx: rx, + initial_screen: StartupDraftInitialScreen::Composer, + pending_paste_newline: None, + } +} + +#[test] +fn startup_draft_renders_full_empty_and_multiline_composer_frames() { + let mut pump = startup_test_pump(std::iter::empty()); + let mut snapshots = Vec::new(); + + for (label, width, text) in [ + ("empty", 48, ""), + ("multiline", 48, "first startup line\nsecond startup line"), + ("narrow", 18, "first startup line\nsecond startup line"), + ] { + pump.bottom_pane + .set_composer_text(text.to_string(), Vec::new(), Vec::new()); + let renderable = startup_draft_renderable(&pump.header, &pump.bottom_pane); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + renderable.desired_height(width), + ); + let mut buffer = Buffer::empty(area); + renderable.render(area, &mut buffer); + let cursor = renderable + .cursor_pos(area) + .expect("keep the editable composer cursor visible below its header"); + let frame = (0..area.height) + .map(|row| { + (0..area.width) + .map(|column| buffer[(column, row)].symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") + .replace(crate::version::CODEX_CLI_VERSION, ""); + + assert!( + cursor.1 >= pump.header.desired_height(width), + "the composer cursor should remain below the startup header" + ); + snapshots.push(format!("{label} ({width} columns):\n{frame}")); + } + + insta::assert_snapshot!("startup_draft_full_frames", snapshots.join("\n---\n")); +} + +#[tokio::test] +async fn startup_draft_hydrates_its_header_without_moving_the_composer() { + let codex_home = tempfile::tempdir().expect("create temporary Codex home"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("build startup configuration"); + let mut pump = startup_test_pump(std::iter::empty()); + let width = 80; + let initial_height = + startup_draft_renderable(&pump.header, &pump.bottom_pane).desired_height(width); + + assert_eq!( + pump.header.raw_lines().last().map(ToString::to_string), + Some("directory: loading".to_string()) + ); + pump.apply_config(&config); + let expected_directory = format!( + "directory: {}", + crate::history_cell::SessionHeaderHistoryCell::format_directory_inner( + config.cwd.as_path(), + /*max_width*/ None, + ) + ); + assert_eq!( + pump.header.raw_lines().last().map(ToString::to_string), + Some(expected_directory) + ); + assert_eq!( + startup_draft_renderable(&pump.header, &pump.bottom_pane).desired_height(width), + initial_height + ); +} + +#[test] +fn startup_draft_preserves_literal_text_without_popups_or_startup_actions() { + let mut pump = startup_test_pump(std::iter::empty()); + for character in "/quit @src $skill /tmp/image.png".chars() { + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE), + ) + .expect("preserve literal startup text"); + } + pump.bottom_pane.flush_composer_paste_burst(); + let expected = "/quit @src $skill /tmp/image.png"; + assert_eq!(pump.bottom_pane.composer_text(), expected); + + for key in [ + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat), + KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT), + KeyEvent::new_with_kind( + KeyCode::Char('x'), + KeyModifiers::NONE, + KeyEventKind::Release, + ), + ] { + handle_startup_draft_key(&mut pump.bottom_pane, key).expect("ignore startup action"); + } + + assert_eq!(pump.bottom_pane.composer_text(), expected); + assert!(pump.bottom_pane.composer_local_images().is_empty()); + assert!(pump.bottom_pane.no_modal_or_popup_active()); + assert!(pump.app_event_rx.try_recv().is_err()); +} + +#[test] +fn startup_draft_preserves_control_cancellation_and_forward_deletion() { + let mut pump = startup_test_pump(std::iter::empty()); + for character in "abc".chars() { + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE), + ) + .expect("buffer startup text before Ctrl+D"); + } + assert!(pump.bottom_pane.is_in_paste_burst()); + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + ) + .expect("move inside the startup draft"); + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + ) + .expect("delete the character under the cursor"); + assert_eq!(pump.bottom_pane.composer_text(), "ab"); + + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + handle_startup_draft_key(&mut pump.bottom_pane, ctrl_c) + .expect("clear nonempty startup draft before cancelling"); + assert_eq!(pump.bottom_pane.composer_text(), ""); + + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ) + .expect("hold a startup character before cancellation"); + handle_startup_draft_key(&mut pump.bottom_pane, ctrl_c) + .expect("clear the held character instead of cancelling startup"); + + for key in [ + ctrl_c, + KeyEvent::new(KeyCode::Char('\u{3}'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::NONE), + ] { + let error = handle_startup_draft_key(&mut pump.bottom_pane, key) + .expect_err("cancel the empty draft using normalized terminal shortcuts"); + assert!(super::StartupCancelled::matches(&error)); + } +} + +#[test] +fn startup_draft_preserves_multiline_editing_without_submitting() { + let mut pump = startup_test_pump(std::iter::empty()); + pump.bottom_pane.insert_str("first"); + + for key in [ + KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT), + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('\n'), KeyModifiers::NONE), + ] { + handle_startup_draft_key(&mut pump.bottom_pane, key).expect("insert a startup newline"); + } + pump.bottom_pane.flush_composer_paste_burst(); + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ) + .expect("ignore startup submission"); + pump.bottom_pane.insert_str("second"); + + assert_eq!(pump.bottom_pane.composer_text(), "first\n\n\n\nsecond"); + assert!(pump.app_event_rx.try_recv().is_err()); +} + +#[tokio::test] +async fn startup_draft_preserves_non_bracketed_multiline_pastes_without_submitting() { + let events = "first line\n\nsecond line\nthird line" + .chars() + .map(|character| { + TuiEvent::Key(KeyEvent::new( + if character == '\n' { + KeyCode::Enter + } else { + KeyCode::Char(character) + }, + KeyModifiers::NONE, + )) + }); + let mut pump = startup_test_pump(events); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + + pump.flush_pending_events(&mut tui) + .await + .expect("preserve multiline non-bracketed paste"); + + assert_eq!( + pump.bottom_pane.composer_text(), + "first line\n\nsecond line\nthird line" + ); + assert!(pump.app_event_rx.try_recv().is_err()); + + pump.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .expect("ignore standalone Enter after the paste finishes"); + assert_eq!( + pump.bottom_pane.composer_text(), + "first line\n\nsecond line\nthird line" + ); + assert!(pump.app_event_rx.try_recv().is_err()); +} + +#[tokio::test] +async fn startup_draft_preserves_deferred_paste_newlines_across_handoff() { + let (event_tx, event_rx) = unbounded_channel(); + let mut pump = startup_test_pump(std::iter::empty()); + pump.events = Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new( + event_rx, + )); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + + for character in "first line".chars() { + pump.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)), + ) + .expect("buffer the first non-bracketed paste line"); + } + pump.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .expect("defer an ambiguous paste newline"); + assert!(pump.pending_paste_newline.is_some()); + + let continuation = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(/*millis*/ 10)).await; + event_tx + .send(TuiEvent::Key(KeyEvent::new( + KeyCode::Char('s'), + KeyModifiers::NONE, + ))) + .expect("continue the paste during handoff"); + }); + pump.flush_pending_paste_newline(&mut tui) + .await + .expect("finish the deferred paste before startup handoff"); + continuation.await.expect("join the paste continuation"); + + assert_eq!(pump.into_draft().text, "first line\ns"); +} + +#[tokio::test] +async fn startup_draft_does_not_turn_a_standalone_enter_into_a_newline_at_handoff() { + let (_event_tx, event_rx) = unbounded_channel(); + let mut pump = startup_test_pump(std::iter::empty()); + pump.events = Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new( + event_rx, + )); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + + for character in "first line".chars() { + pump.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)), + ) + .expect("buffer startup typing"); + } + pump.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .expect("keep standalone startup Enter provisional"); + assert!(pump.pending_paste_newline.is_some()); + + pump.flush_pending_paste_newline(&mut tui) + .await + .expect("discard standalone Enter after its lookahead expires"); + + assert_eq!(pump.into_draft().text, "first line"); +} + +#[test] +fn startup_draft_allows_local_editor_shortcuts_without_startup_actions() { + let mut pump = startup_test_pump(std::iter::empty()); + pump.bottom_pane.insert_str("first second"); + + for (key, expected_cursor) in [ + ( + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), + "first ".len(), + ), + (KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), 0), + ( + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), + "first second".len(), + ), + ] { + handle_startup_draft_key(&mut pump.bottom_pane, key).expect("apply editor movement"); + assert_eq!(pump.bottom_pane.composer_cursor(), expected_cursor); + } + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), + ) + .expect("delete the previous word"); + assert_eq!(pump.bottom_pane.composer_text(), "first "); + + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.editor.move_line_start = vec![crate::key_hint::ctrl(KeyCode::Char('z'))]; + keymap.editor.move_line_end = vec![crate::key_hint::ctrl(KeyCode::Char('v'))]; + keymap.editor.move_left = vec![crate::key_hint::ctrl(KeyCode::Char('s'))]; + keymap.editor.insert_newline = vec![crate::key_hint::plain(KeyCode::Enter)]; + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('s'))]; + pump.bottom_pane.set_keymap_bindings(&keymap); + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL), + ) + .expect("use a configured editor movement"); + assert_eq!(pump.bottom_pane.composer_cursor(), 0); + + for key in [ + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('\u{16}'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('\u{12}'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('\u{13}'), KeyModifiers::NONE), + ] { + handle_startup_draft_key(&mut pump.bottom_pane, key) + .expect("ignore image, history, and configured submission actions"); + } + + keymap.composer.submit = vec![crate::key_hint::plain(KeyCode::Char('s'))]; + keymap.composer.queue = vec![crate::key_hint::plain(KeyCode::Char('q'))]; + keymap.composer.history_search_previous = vec![crate::key_hint::plain(KeyCode::Char('r'))]; + pump.bottom_pane.set_keymap_bindings(&keymap); + for character in ['s', 'q', 'r'] { + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE), + ) + .expect("ignore configured plain composer actions"); + } + assert_eq!(pump.bottom_pane.composer_cursor(), 0); + assert_eq!(pump.bottom_pane.composer_text(), "first "); + assert!(pump.app_event_rx.try_recv().is_err()); +} + +#[cfg(target_os = "windows")] +#[test] +fn startup_draft_preserves_windows_altgr_text_input() { + let mut pump = startup_test_pump(std::iter::empty()); + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new( + KeyCode::Char('@'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ), + ) + .expect("preserve printable AltGr input"); + pump.bottom_pane.flush_composer_paste_burst(); + assert_eq!(pump.bottom_pane.composer_text(), "@"); +} + +#[tokio::test] +async fn startup_draft_applies_paste_burst_preferences_without_losing_buffered_input() { + let codex_home = tempfile::tempdir().expect("create temporary Codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("build startup configuration"); + let mut pump = startup_test_pump(std::iter::empty()); + + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + ) + .expect("buffer input before startup configuration loads"); + assert!(pump.bottom_pane.is_in_paste_burst()); + assert_eq!(pump.bottom_pane.composer_text(), ""); + + config.disable_paste_burst = true; + pump.apply_config(&config); + assert_eq!(pump.bottom_pane.composer_text(), "a"); + assert!(!pump.bottom_pane.is_in_paste_burst()); + + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE), + ) + .expect("insert text immediately when paste detection is disabled"); + assert_eq!(pump.bottom_pane.composer_text(), "ab"); + assert!(!pump.bottom_pane.is_in_paste_burst()); + + config.disable_paste_burst = false; + pump.apply_config(&config); + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), + ) + .expect("restore paste detection after a later configuration change"); + assert_eq!(pump.bottom_pane.composer_text(), "ab"); + assert!(pump.bottom_pane.is_in_paste_burst()); + + config.disable_paste_burst = true; + pump.apply_config(&config); + assert_eq!(pump.bottom_pane.composer_text(), "abc"); + assert!(!pump.bottom_pane.is_in_paste_burst()); + assert!(pump.app_event_rx.try_recv().is_err()); +} + +#[tokio::test] +async fn startup_draft_applies_editor_keymap_without_enabling_vim() { + let codex_home = tempfile::tempdir().expect("create temporary Codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("build startup configuration"); + config.tui_vim_mode_default = true; + config.tui_keymap.editor.move_line_start = Some(codex_config::types::KeybindingsSpec::One( + codex_config::types::KeybindingSpec("ctrl-z".to_string()), + )); + let mut pump = startup_test_pump(std::iter::empty()); + pump.bottom_pane.insert_str("draft"); + pump.apply_config(&config); + assert!(!pump.bottom_pane.composer_is_vim_enabled()); + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ) + .expect("insert ordinary text despite the configured Vim mode"); + pump.bottom_pane.flush_composer_paste_burst(); + assert_eq!(pump.bottom_pane.composer_text(), "draftx"); + handle_startup_draft_key( + &mut pump.bottom_pane, + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL), + ) + .expect("honor a configured safe editor shortcut"); + assert_eq!(pump.bottom_pane.composer_cursor(), 0); +} + +#[tokio::test] +async fn startup_draft_waits_for_session_picker_before_accepting_input() { + let mut pump = startup_test_pump( + [ + TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + TuiEvent::Paste("not a draft".to_string()), + TuiEvent::Draw, + ] + .into_iter(), + ); + pump.initial_screen = StartupDraftInitialScreen::SessionPicker; + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + + pump.flush_pending_events(&mut tui) + .await + .expect("ignore input before the session picker owns the screen"); + assert!(pump.bottom_pane.composer_is_empty()); + assert!(tui.terminal.viewport_area.is_empty()); + + pump.show(&mut tui) + .expect("show the composer after a session is selected"); + assert!(!tui.terminal.viewport_area.is_empty()); + pump.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)), + ) + .expect("edit the composer after the session picker exits"); + pump.bottom_pane.flush_composer_paste_burst(); + assert_eq!(pump.bottom_pane.composer_text(), "y"); + + let ctrl_c = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + pump.handle_event(&mut tui, ctrl_c.clone()) + .expect("clear the visible startup draft before cancelling"); + assert!(pump.bottom_pane.composer_is_empty()); + let error = pump + .handle_event(&mut tui, ctrl_c) + .expect_err("cancel the visible startup composer"); + assert!(super::StartupCancelled::matches(&error)); +} + +#[tokio::test] +async fn startup_draft_allows_cancellation_before_session_picker_appears() { + let mut pump = startup_test_pump(std::iter::once(TuiEvent::Key(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + )))); + pump.initial_screen = StartupDraftInitialScreen::SessionPicker; + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + + let error = pump + .flush_pending_events(&mut tui) + .await + .expect_err("cancel startup before the session picker appears"); + assert!(super::StartupCancelled::matches(&error)); +} + +#[tokio::test] +async fn startup_draft_flushes_large_event_backlogs_without_submitting_or_retaining_app_events() { + let events = (0..=1024).map(|_| TuiEvent::Draw).chain([ + TuiEvent::Key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE)), + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + TuiEvent::Paste("\u{1b}[31m last\r\nline ".to_string()), + TuiEvent::Key(KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE)), + TuiEvent::Key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)), + TuiEvent::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + ]); + let mut pump = startup_test_pump(events); + pump.bottom_pane.insert_str("trusted"); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + pump.flush_pending_events(&mut tui) + .await + .expect("flush safe startup input"); + + assert!(pump.app_event_rx.try_recv().is_err()); + let draft = pump.into_draft(); + assert_eq!(draft.text, "trusted! last\nline @s"); + assert_eq!(draft.cursor, draft.text.len() - 1); + assert!(draft.local_images.is_empty()); +} + +#[tokio::test] +async fn startup_draft_preserves_large_pastes_without_attaching_images() { + let pasted = "x".repeat(/*n*/ 1_100); + let mut pump = startup_test_pump(std::iter::once(TuiEvent::Paste(pasted.clone()))); + let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal"); + pump.flush_pending_events(&mut tui) + .await + .expect("preserve large startup paste"); + + let draft = pump.into_draft(); + assert_eq!(draft.pending_pastes, vec![(draft.text.clone(), pasted)]); + assert_eq!(draft.cursor, draft.text.len()); + assert!(draft.local_images.is_empty()); +} diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs new file mode 100644 index 0000000000..616851afd1 --- /dev/null +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -0,0 +1,538 @@ +//! Orchestrates startup while the provisional composer owns terminal input. +//! +//! Lightweight validation runs before acquiring the terminal. Once the draft is visible, slow +//! configuration and app-server initialization remain responsive to safe local editing. + +use super::*; + +pub(super) async fn run_main_inner( + mut cli: Cli, + arg0_paths: Arg0DispatchPaths, + loader_overrides: LoaderOverrides, + explicit_remote_endpoint: Option, +) -> std::io::Result { + let strict_config = cli.strict_config; + let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox { + ( + Some(SandboxMode::DangerFullAccess), + Some(AskForApproval::Never.to_core()), + ) + } else { + ( + cli.sandbox_mode.map(Into::::into), + cli.approval_policy.map(Into::into), + ) + }; + + cli.shared + .take_auto_review_config_overrides(&mut cli.config_overrides); + + // Map the legacy --search flag to the canonical web_search mode. + if cli.web_search { + cli.config_overrides + .raw_overrides + .push("web_search=\"live\"".to_string()); + } + + // When using `--oss`, let the bootstrapper pick the model (defaulting to + // gpt-oss:20b) and ensure it is present locally. Also, force the built‑in + let raw_overrides = cli.config_overrides.raw_overrides.clone(); + // `oss` model provider. + let overrides_cli = codex_utils_cli::CliConfigOverrides { raw_overrides }; + let cli_kv_overrides = match overrides_cli.parse_overrides() { + // Parse `-c` overrides from the CLI. + Ok(v) => v, + #[allow(clippy::print_stderr)] + Err(e) => { + eprintln!("Error parsing -c overrides: {e}"); + std::process::exit(1); + } + }; + + // we load config.toml here to determine project state. + #[allow(clippy::print_stderr)] + let codex_home = match find_codex_home() { + Ok(codex_home) => codex_home.to_path_buf(), + Err(err) => { + eprintln!("Error finding codex home: {err}"); + std::process::exit(1); + } + }; + + let mut launch_loader_overrides = loader_overrides.clone(); + if let Some(profile_v2) = cli.config_profile_v2.as_ref() { + let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2); + launch_loader_overrides.user_config_path = Some(user_config_path); + launch_loader_overrides.user_config_profile = Some(profile_v2.clone()); + } + let workload_identity_selected = is_workload_identity_selected(); + + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + let validation_target = app_server_target_for_launch( + explicit_remote_endpoint.clone(), + /*default_daemon_socket*/ None, + /*can_reuse_implicit_local_daemon*/ false, + workload_identity_selected, + )?; + let validation_environment_manager = + if should_load_configured_environments(&loader_overrides, &validation_target) { + EnvironmentManager::prepare_from_codex_home(&codex_home).await + } else { + EnvironmentManager::prepare_from_env().await + } + .map_err(std::io::Error::other)?; + let validation_cwd = config_cwd_for_app_server_target( + cli.cwd.as_deref(), + &validation_target, + validation_environment_manager.default_environment_is_remote(), + )?; + let mut validation_loader_overrides = launch_loader_overrides.clone(); + validation_loader_overrides.ignore_login_requirements = + validation_target.uses_remote_workspace(); + let validation_bootstrap = load_bootstrap_config_or_exit( + &codex_home, + validation_cwd.as_ref(), + cli_kv_overrides.clone(), + validation_loader_overrides.clone(), + strict_config, + CloudConfigBundleLoader::default(), + ) + .await; + let validation_cloud_config_bundle = if workload_identity_selected { + cloud_config_bundle_for_app_server_target( + &validation_target, + &validation_bootstrap, + &codex_home, + ) + .await? + } else { + CloudConfigBundleLoader::default() + }; + load_config_or_exit( + cli_kv_overrides.clone(), + ConfigOverrides { + model: cli.model.clone(), + approval_policy, + sandbox_mode, + cwd: validation_cwd.map(AbsolutePathBuf::into_path_buf), + model_provider: cli + .oss + .then(|| { + resolve_oss_provider( + cli.oss_provider.as_deref(), + &validation_bootstrap.config_toml, + ) + }) + .flatten(), + bypass_hook_trust: cli.bypass_hook_trust.then_some(true), + additional_writable_roots: cli.add_dir.clone(), + ..Default::default() + }, + validation_loader_overrides, + validation_cloud_config_bundle, + strict_config, + ) + .await; + } + + let initial_screen = if cli.resume_picker || cli.fork_picker { + startup_draft::StartupDraftInitialScreen::SessionPicker + } else { + startup_draft::StartupDraftInitialScreen::Composer + }; + let mut startup_draft = startup_draft::StartupDraft::new(initial_screen)?; + + let reuse_implicit_local_daemon = !workload_identity_selected + && can_reuse_implicit_local_daemon( + &cli_kv_overrides, + &launch_loader_overrides, + strict_config, + cli.bypass_hook_trust, + ); + let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon { + startup_draft + .run_until(maybe_probe_default_daemon_socket(&codex_home)) + .await? + } else { + None + }; + let app_server_target = app_server_target_for_launch( + explicit_remote_endpoint, + default_daemon, + reuse_implicit_local_daemon, + workload_identity_selected, + )?; + let remote_cwd_override = cli + .cwd + .clone() + .filter(|_| app_server_target.uses_remote_workspace()); + + let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( + 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)?; + 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?; + let bootstrap_config_toml = &bootstrap_config.config_toml; + let cloud_config_bundle = startup_draft + .run_until(cloud_config_bundle_for_app_server_target( + &app_server_target, + &bootstrap_config, + &codex_home, + )) + .await??; + + let cwd_override = if app_server_target.uses_remote_workspace() { + None + } else { + cwd.clone() + }; + + let mut manually_selected_oss_provider = None; + let model_provider_override = if cli.oss { + let bootstrap_config_with_cloud_config; + let config_toml_for_oss = if cli.oss_provider.is_none() { + // The first load intentionally skips cloud config so we can read + // auth/base-url settings needed to fetch the bundle. If OSS mode + // needs a default provider from config, reload with the bundle. + bootstrap_config_with_cloud_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, + cloud_config_bundle.clone(), + )) + .await?; + &bootstrap_config_with_cloud_config.config_toml + } else { + bootstrap_config_toml + }; + + let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), config_toml_for_oss); + + if let Some(provider) = resolved { + Some(provider) + } else { + let selection = match startup_draft + .run_until(oss_selection::detect_oss_provider()) + .await? + { + oss_selection::OssProviderDetection::AutoSelected(selection) => selection, + oss_selection::OssProviderDetection::NeedsSelection { + lmstudio_status, + ollama_status, + } => { + startup_draft.flush_pending_events().await?; + startup_draft + .tui_mut() + .with_restored(|| { + oss_selection::select_oss_provider(lmstudio_status, ollama_status) + }) + .await? + } + }; + let provider = selection.provider; + if provider == "__CANCELLED__" { + return Err(std::io::Error::other( + "OSS provider selection was cancelled by user", + )); + } + if selection.manually_selected { + manually_selected_oss_provider = Some(provider.clone()); + } + Some(provider) + } + } else { + None + }; + + // When using `--oss`, let the bootstrapper pick the model based on selected provider + let model = if let Some(model) = &cli.model { + Some(model.clone()) + } else if cli.oss { + // Use the provider from model_provider_override + model_provider_override + .as_ref() + .and_then(|provider_id| get_default_model_for_oss_provider(provider_id)) + .map(std::borrow::ToOwned::to_owned) + } else { + None // No model specified, will use the default. + }; + + let additional_dirs = cli.add_dir.clone(); + + let overrides = ConfigOverrides { + model, + approval_policy, + sandbox_mode, + cwd: cwd_override, + model_provider: model_provider_override.clone(), + codex_self_exe: arg0_paths.codex_self_exe.clone(), + codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), + show_raw_agent_reasoning: cli.oss.then_some(true), + bypass_hook_trust: cli.bypass_hook_trust.then_some(true), + additional_writable_roots: additional_dirs, + ..Default::default() + }; + + let config = startup_draft + .run_until(load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + loader_overrides.clone(), + cloud_config_bundle.clone(), + strict_config, + )) + .await?; + startup_draft.apply_config(&config); + + let cloud_config_bundle = if workload_identity_selected { + cloud_config_bundle + } else { + startup_draft + .run_until(cloud_config_bundle_loader_for_storage( + app_server_target.auth_config_for_cloud_loader(config.auth_config()), + /*enable_codex_api_key_env*/ false, + )) + .await?? + }; + let environment_manager = Arc::new( + prepared_environment_manager + .build(Some(local_runtime_paths), config.http_client_factory()) + .map_err(std::io::Error::other)?, + ); + + remove_legacy_tui_log_file(config.codex_home.as_path()); + + let otel_originator = originator().value; + let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + codex_app_server_client::build_otel_provider( + &config, + env!("CARGO_PKG_VERSION"), + /*service_name_override*/ None, + /*default_analytics_enabled*/ true, + ) + })) { + Ok(Ok(otel)) => otel, + Ok(Err(e)) => { + startup_draft.flush_pending_events().await?; + startup_draft + .tui_mut() + .with_restored(|| async { + #[allow(clippy::print_stderr)] + { + eprintln!("Could not create otel exporter: {e}"); + } + }) + .await; + None + } + Err(_) => { + #[allow(clippy::print_stderr)] + { + eprintln!("Could not create otel exporter: panicked during initialization"); + } + startup_draft.tui_mut().recover_after_caught_panic()?; + None + } + }; + if let Some(metrics) = otel.as_ref().and_then(codex_otel::OtelProvider::metrics) { + let _ = codex_otel::record_process_start_once(metrics, otel_originator.as_str()); + let telemetry = + codex_rollout::sqlite_telemetry_recorder(metrics.clone(), otel_originator.as_str()); + let _ = codex_state::install_process_db_telemetry(telemetry); + } + let state_db = startup_draft + .run_until(init_state_db_for_app_server_target( + &config, + &app_server_target, + )) + .await??; + let config_toml_log_dir_configured = config + .config_layer_stack + .effective_config() + .as_table() + .is_some_and(|table| table.contains_key("log_dir")) + || config + .config_layer_stack + .requirements_toml() + .log_dir + .is_some(); + + set_default_client_residency_requirement(config.enforce_residency.value()); + + if let Some(warning) = add_dir_warning_message( + &cli.add_dir, + &config.permissions.effective_permission_profile(), + config.cwd.as_path(), + ) { + #[allow(clippy::print_stderr)] + { + restore_terminal_before_fatal_exit(); + eprintln!("Error adding directories: {warning}"); + std::process::exit(1); + } + } + + if !app_server_target.uses_remote_workspace() && !workload_identity_selected { + #[allow(clippy::print_stderr)] + if let Err(err) = startup_draft + .run_until(enforce_login_restrictions(&config.auth_config())) + .await? + { + restore_terminal_before_fatal_exit(); + eprintln!("{err}"); + std::process::exit(1); + } + } + + let (tui_file_layer, _tui_file_log_guard) = if config_toml_log_dir_configured { + let log_dir = config.log_dir.clone(); + std::fs::create_dir_all(&log_dir)?; + let mut log_file_opts = OpenOptions::new(); + log_file_opts.create(true).append(true); + + // Ensure the file is only readable and writable by the current user. + // Doing the equivalent to `chmod 600` on Windows is quite a bit more + // code and requires the Windows API crates. + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + log_file_opts.mode(0o600); + } + + let log_file = log_file_opts.open(log_dir.join(TUI_LOG_FILE_NAME))?; + let (non_blocking, guard) = non_blocking(log_file); + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info") + }); + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_target(true) + .with_ansi(false) + .with_span_events( + tracing_subscriber::fmt::format::FmtSpan::NEW + | tracing_subscriber::fmt::format::FmtSpan::CLOSE, + ) + .with_filter(env_filter); + (Some(file_layer), Some(guard)) + } else { + (None, None) + }; + + let feedback = codex_feedback::CodexFeedback::new(); + let feedback_layer = feedback.logger_layer(); + let feedback_metadata_layer = feedback.metadata_layer(); + + if cli.oss && model_provider_override.is_some() { + // We're in the oss section, so provider_id should be Some + // Let's handle None case gracefully though just in case + let provider_id = match model_provider_override.as_ref() { + Some(id) => id, + None => { + error!("OSS provider unexpectedly not set when oss flag is used"); + return Err(std::io::Error::other( + "OSS provider not set but oss flag was used", + )); + } + }; + startup_draft.flush_pending_events().await?; + startup_draft + .tui_mut() + .with_restored(|| async { + // Provider setup may print progress or block in an external downloader. + // Restore ordinary signal handling so Ctrl+C can interrupt that process. + crossterm::terminal::disable_raw_mode()?; + ensure_oss_provider_ready(provider_id, &config).await + }) + .await?; + } + + let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer()); + + let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); + + let log_db = state_db.clone().map(log_db::start); + let log_db_layer = log_db + .clone() + .map(|layer| layer.with_filter(log_db::default_filter())); + + let _ = tracing_subscriber::registry() + .with(tui_file_layer) + .with(feedback_layer) + .with(feedback_metadata_layer) + .with(log_db_layer) + .with(otel_logger_layer) + .with(otel_tracing_layer) + .try_init(); + + let app_result = run_ratatui_app( + cli, + arg0_paths, + loader_overrides, + strict_config, + app_server_target, + remote_cwd_override, + config, + manually_selected_oss_provider, + overrides, + cli_kv_overrides, + cloud_config_bundle, + feedback, + log_db, + state_db, + environment_manager, + startup_draft, + ) + .await + .map_err(|err| { + err.downcast::() + .unwrap_or_else(|err| std::io::Error::other(err.to_string())) + }); + + if let Some(otel) = otel + && let Err(err) = otel + .shutdown_with_timeout(INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT) + .await + { + warn!(error = %err, "failed to finish interactive telemetry shutdown"); + } + + app_result +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 49dca1d545..639f88fa9b 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -693,6 +693,15 @@ impl Tui { self.event_broker.resume_events(); } + /// Reclaim terminal modes and stderr after a panic hook ran inside a recovery boundary. + pub(crate) fn recover_after_caught_panic(&mut self) -> Result<()> { + set_modes()?; + self._stderr_guard.recover_after_caught_panic()?; + self.terminal.invalidate_viewport(); + self.frame_requester().schedule_frame(); + Ok(()) + } + /// Discard buffered typeahead before a startup screen that can confirm an action. /// /// Startup probes can leave parsed key events in crossterm's queue, while later bootstrap diff --git a/codex-rs/tui/src/tui/terminal_stderr.rs b/codex-rs/tui/src/tui/terminal_stderr.rs index ed4e7e3cf4..dc1f95029e 100644 --- a/codex-rs/tui/src/tui/terminal_stderr.rs +++ b/codex-rs/tui/src/tui/terminal_stderr.rs @@ -53,6 +53,24 @@ impl TerminalStderrGuard { Ok(Self { active: false }) } + /// Reclaim stderr after a caught panic temporarily restored terminal ownership. + pub(super) fn recover_after_caught_panic(&mut self) -> io::Result<()> { + if !self.active { + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + let mut state = lock_state()?; + if !state.owner_active { + suppress_locked(&mut state)?; + state.owner_active = true; + } + } + + Ok(()) + } + #[cfg(target_os = "macos")] fn install_suppression() -> io::Result { let mut state = lock_state()?; @@ -239,7 +257,7 @@ mod tests { let mut output = tempfile::tempfile()?; let capture = CapturedStderr::start(&output)?; - let _guard = TerminalStderrGuard::install_suppression()?; + let mut guard = TerminalStderrGuard::install_suppression()?; write_stderr("hidden while active\n")?; pause()?; write_stderr("visible while paused\n")?; @@ -247,11 +265,19 @@ mod tests { write_stderr("hidden after resume\n")?; finish()?; write_stderr("visible after finish\n")?; + guard.recover_after_caught_panic()?; + write_stderr("hidden after caught panic\n")?; + finish()?; + write_stderr("visible after recovered ownership\n")?; + drop(capture); output.rewind()?; let mut captured = String::new(); output.read_to_string(&mut captured)?; - assert_eq!(captured, "visible while paused\nvisible after finish\n"); + assert_eq!( + captured, + "visible while paused\nvisible after finish\nvisible after recovered ownership\n" + ); Ok(()) } diff --git a/codex-rs/tui/src/tui_startup_tests.rs b/codex-rs/tui/src/tui_startup_tests.rs index 96d0d91259..cb89994df8 100644 --- a/codex-rs/tui/src/tui_startup_tests.rs +++ b/codex-rs/tui/src/tui_startup_tests.rs @@ -12,9 +12,6 @@ use std::time::Instant; use crossterm::event::Event; use crossterm::event::KeyCode; -use super::InitializedTerminal; -use super::Tui; - const STARTUP_INPUT_CHILD_TEST: &str = "tui::startup_tests::startup_typeahead_pty_child"; #[test] @@ -54,6 +51,10 @@ fn startup_preserves_typeahead_and_discards_buffered_action_key_after_first_draw .expect("queue typeahead"); let signals = tempfile::tempdir().expect("create pty synchronization directory"); + let draft_ready = signals.path().join("draft-ready"); + let draft_queued = signals.path().join("draft-queued"); + let handoff_ready = signals.path().join("handoff-ready"); + let handoff_queued = signals.path().join("handoff-queued"); let action_ready = signals.path().join("action-ready"); let action_queued = signals.path().join("action-queued"); let fresh_ready = signals.path().join("fresh-ready"); @@ -75,8 +76,36 @@ fn startup_preserves_typeahead_and_discards_buffered_action_key_after_first_draw .stderr(Stdio::piped()) .spawn() .expect("spawn startup input pty child"); + let mut output = master.try_clone().expect("clone pty output reader"); + let output_reader = std::thread::spawn(move || { + let mut captured = Vec::new(); + let mut buffer = [0; 4096]; + loop { + match output.read(&mut buffer) { + Ok(0) => break, + Ok(read) => captured.extend_from_slice(&buffer[..read]), + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => break, + } + } + captured + }); let result = (|| -> Result<(), String> { + wait_for_file(&draft_ready)?; + master + .write_all(b"abc\r\x1b[DZ") + .map_err(|err| format!("failed to queue editable startup input: {err}"))?; + std::fs::write(&draft_queued, []) + .map_err(|err| format!("failed to signal editable startup input: {err}"))?; + + wait_for_file(&handoff_ready)?; + master + .write_all(b"!\x1b[D") + .map_err(|err| format!("failed to queue post-handoff startup input: {err}"))?; + std::fs::write(&handoff_queued, []) + .map_err(|err| format!("failed to signal post-handoff startup input: {err}"))?; + for suffix in ["y", "1"] { let ready = signals.path().join(format!("alt-{suffix}-ready")); wait_for_file(&ready)?; @@ -146,6 +175,7 @@ fn startup_preserves_typeahead_and_discards_buffered_action_key_after_first_draw let _ = child.kill(); } let status = child.wait().expect("wait for startup input pty child"); + let output = output_reader.join().expect("join pty output reader"); let mut stderr = String::new(); child .stderr @@ -155,10 +185,11 @@ fn startup_preserves_typeahead_and_discards_buffered_action_key_after_first_draw .expect("read child stderr"); assert!( result.is_ok() && status.success(), - "startup input pty child failed: {}\n{stderr}", + "startup input pty child failed: {}\n{stderr}\n{}", result .err() - .unwrap_or_else(|| "child exited unsuccessfully".to_string()) + .unwrap_or_else(|| "child exited unsuccessfully".to_string()), + String::from_utf8_lossy(&output) ); } @@ -169,11 +200,10 @@ async fn startup_typeahead_pty_child() { return; }; let signals = PathBuf::from(signals); - let InitializedTerminal { - terminal, - enhanced_keys_supported, - stderr_guard, - } = super::init().expect("initialize terminal"); + let mut startup_draft = crate::startup_draft::StartupDraft::new( + crate::startup_draft::StartupDraftInitialScreen::Composer, + ) + .expect("initialize the real startup composer"); let mut typeahead = Vec::new(); for _ in 0..5 { @@ -209,7 +239,45 @@ async fn startup_typeahead_pty_child() { Event::Key(key) if key.code == KeyCode::Up )); - let mut tui = Tui::new(terminal, enhanced_keys_supported, stderr_guard); + std::fs::write(signals.join("draft-ready"), []).expect("signal editable startup composer"); + let draft_queued = signals.join("draft-queued"); + let startup_result = startup_draft + .run_until(async { + while !draft_queued.exists() { + tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await; + } + tokio::time::sleep(Duration::from_millis(/*millis*/ 100)).await; + "startup completed" + }) + .await + .expect("edit the real composer while startup work is pending"); + assert_eq!(startup_result, "startup completed"); + + let (mut tui, mut terminal_restore_guard, mut startup_draft) = startup_draft.into_parts(); + std::fs::write(signals.join("handoff-ready"), []).expect("signal startup terminal handoff"); + let handoff_queued = signals.join("handoff-queued"); + startup_draft + .run_until(&mut tui, async { + while !handoff_queued.exists() { + tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await; + } + tokio::time::sleep(Duration::from_millis(/*millis*/ 100)).await; + }) + .await + .expect("keep the real composer editable after terminal ownership transfers"); + startup_draft + .flush_pending_events(&mut tui) + .await + .expect("flush real startup terminal input"); + tui.pause_events(); + let draft = startup_draft.into_draft(); + assert_eq!( + draft.text, "abZ!c", + "startup Enter must not submit the draft" + ); + assert_eq!(draft.cursor, 3, "startup cursor edits must survive handoff"); + assert!(draft.local_images.is_empty()); + tui.draw(u16::MAX, |_| {}).expect("draw actionable screen"); crossterm::event::buffer_input(b"\x1b").expect("buffer a standalone escape"); let escape_started_at = Instant::now(); @@ -308,7 +376,9 @@ async fn startup_typeahead_pty_child() { .expect_err("an unresolved escape at a protected boundary must fail closed"); assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); - super::restore_after_exit().expect("restore terminal"); + terminal_restore_guard + .restore() + .expect("restore terminal after startup draft handoff"); } fn wait_for_file(path: &Path) -> Result<(), String> { diff --git a/codex-rs/tui/tests/suite/focus_palette.rs b/codex-rs/tui/tests/suite/focus_palette.rs index c820024a4e..5b96726590 100644 --- a/codex-rs/tui/tests/suite/focus_palette.rs +++ b/codex-rs/tui/tests/suite/focus_palette.rs @@ -16,7 +16,8 @@ use anyhow::bail; use anyhow::ensure; use tempfile::TempDir; -const STARTUP_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 15); +// Full startup continues after the composer first appears and can be slower under Rosetta in CI. +const STARTUP_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); const FOCUS_INPUT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5); const FOCUS_PROBE_INPUT: &str = "focus-palette-24527";