diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 6fed3e3561..0246916357 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -135,6 +135,8 @@ impl KeybindingsSpec { pub struct TuiGlobalKeymap { /// Prefix for persistent, context-aware shortcut menus. pub leader: Option, + /// Change where a new conversation runs. + pub change_host: Option, /// Open the shared agent-session overview. pub open_agents: Option, /// Open the transcript overlay. diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 60e713fcf5..5fc4b9346f 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -872,6 +872,11 @@ impl QuickDial { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct Tui { + /// Preferred hosts for new conversations, indexed by project and local user identity. + /// Read from the user configuration only; values are never endpoints or credentials. + #[serde(default)] + pub default_hosts: BTreeMap>, + #[serde(default, flatten)] pub notification_settings: TuiNotificationSettings, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index caced41390..8a2b679775 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5146,6 +5146,17 @@ "description": "Deselect read-only transcript or footer text after Copy on select confirms delivery. Editable composer selections remain visible. Defaults to `false` and has no effect when Copy on select is off.", "type": "boolean" }, + "default_hosts": { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "default": {}, + "description": "Preferred hosts for new conversations, indexed by project and local user identity. Read from the user configuration only; values are never endpoints or credentials.", + "type": "object" + }, "disable_paste_burst": { "description": "When true, disables burst-paste detection for typed input entirely. All characters are inserted as they are received, and no buffering or placeholder replacement will occur for fast keypress bursts. Overrides the legacy top-level `disable_paste_burst` setting. Defaults to `false`.", "type": "boolean" @@ -5242,6 +5253,7 @@ "yank": null }, "global": { + "change_host": null, "clear_terminal": null, "copy": null, "find_transcript": null, @@ -6035,6 +6047,14 @@ "additionalProperties": false, "description": "Global keybindings. These are used when a context does not define an override.", "properties": { + "change_host": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Change where a new conversation runs." + }, "clear_terminal": { "allOf": [ { @@ -6275,6 +6295,7 @@ } ], "default": { + "change_host": null, "clear_terminal": null, "copy": null, "find_transcript": null, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 714a0da53b..95de21d773 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1393,6 +1393,7 @@ fn config_toml_deserializes_model_availability_nux() { assert_eq!( cfg.tui.expect("tui config should deserialize"), Tui { + default_hosts: Default::default(), notification_settings: TuiNotificationSettings::default(), animations: true, screen_reader_detection_done: None, @@ -4754,6 +4755,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { assert_eq!( tui, Tui { + default_hosts: Default::default(), notification_settings: TuiNotificationSettings::default(), animations: true, screen_reader_detection_done: None, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index f3a81fc4ef..7a981aa784 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -216,7 +216,7 @@ pub(crate) mod app_server_requests; mod app_server_thread_ownership; mod backend_banner_fallback; mod backend_events; -mod backend_selection; +pub(crate) mod backend_selection; pub(crate) mod backend_sessions; mod background_requests; mod composer_hints; @@ -549,6 +549,7 @@ pub(crate) struct App { /// Legacy bootstrap and server-setting inputs; local preferences live in `local_settings`. pub(crate) config: Config, pub(crate) local_settings: crate::local_settings::LocalSettings, + backend_preference_context: backend_selection::BackendPreferenceContext, launch_cwd: PathBuf, /// Resume anchor selected by `/cd`; ordinary resumes retain the immutable launch cwd. runtime_working_directory_override: Option, diff --git a/codex-rs/tui/src/app/backend_selection.rs b/codex-rs/tui/src/app/backend_selection.rs index 5e777ba283..190abf2eea 100644 --- a/codex-rs/tui/src/app/backend_selection.rs +++ b/codex-rs/tui/src/app/backend_selection.rs @@ -1,10 +1,352 @@ -//! Prepare saved conversations through their owner without mutating the UI on failure. +//! Prepare conversations through their owning backend before replacing the UI. + use super::backend_sessions::BackendSessions; +use super::backend_sessions::SessionSlot; use super::resume_preparation::ResumeSource; +use super::session_lifecycle::PreparedFreshSession; +use super::session_lifecycle::ThreadAttachPresentation; use super::*; use codex_app_server_protocol::ThreadItemPayload; +pub(super) enum NewBackendSession { + Fresh { name: Option }, + CarryDraft, +} + +/// Host inputs for the new-thread preference. A cloud thread's cwd and credentials +/// belong to the executor and must never determine where this preference is saved. +pub(crate) struct BackendPreferenceContext { + pub(crate) local_cwd: Option, + auth: codex_login::AuthConfig, + project_root_markers: Vec, +} + +impl BackendPreferenceContext { + pub(crate) fn new(config: &Config, local_cwd: Option) -> Self { + let project_root_markers = config + .config_layer_stack + .layers_high_to_low() + .filter(|layer| !matches!(layer.name, codex_config::ConfigLayerSource::Project { .. })) + .find_map(|layer| { + codex_config::project_root_markers_from_config(&layer.config) + .ok() + .flatten() + }) + .unwrap_or_else(codex_config::default_project_root_markers); + Self { + local_cwd, + auth: config.auth_config(), + project_root_markers, + } + } + + async fn scope(&self) -> Option<(String, String)> { + let cwd = AbsolutePathBuf::from_absolute_path(self.local_cwd.as_deref()?).ok()?; + let project = codex_config::loader::find_project_root( + codex_exec_server::LOCAL_FS.as_ref(), + &cwd, + &self.project_root_markers, + ) + .await + .ok()?; + let auth = self + .auth + .load_auth(/*enable_codex_api_key_env*/ false) + .await + .ok()?; + let user = if let Some(id) = auth + .as_ref() + .and_then(codex_login::CodexAuth::get_chatgpt_user_id) + { + format!("chatgpt:{id}") + } else if auth + .as_ref() + .is_some_and(codex_login::CodexAuth::is_chatgpt_auth) + { + return None; + } else { + // A local profile without an account is already private to its CODEX_HOME. + "local".to_string() + }; + Some(( + codex_config::loader::project_trust_key(project.as_path()), + user, + )) + } +} + impl App { + pub(super) async fn new_thread_host_preference(&self) -> Option<&str> { + let defaults = &self.local_settings.tui.default_hosts; + if defaults.is_empty() { + return None; + } + let (project, user) = self.backend_preference_context.scope().await?; + defaults.get(&project)?.get(&user).map(String::as_str) + } + + pub(super) async fn other_new_session_backend( + &self, + backends: &BackendSessions, + ) -> Option { + backends + .preferred_listening_backend(self.new_thread_host_preference().await) + .filter(|owner| *owner != backends.displayed_owner()) + } + + pub(super) fn can_cycle_conversation_backend(&self) -> bool { + self.voice_owner_thread_id().is_none() + && self.chat_widget.can_choose_new_backend() + && self + .current_displayed_thread_id() + .and_then(|id| self.thread_event_channels.get(&id)) + .and_then(|channel| channel.store.try_lock().ok()) + .is_some_and(|store| store.session.is_some() && store.latest_turn_id.is_none()) + } + + pub(super) async fn start_new_session_on_other_backend( + &mut self, + tui: &mut tui::Tui, + backends: &mut BackendSessions, + owner: crate::backend_id::BackendId, + session: NewBackendSession, + ) -> Result<(), String> { + let slot = backends + .listening_slot(owner) + .ok_or("That connection is unavailable. Choose another and try again.")?; + if self.windows_sandbox_blocks_thread_switch() + || self.reject_pending_permission_root_switch() + { + return Ok(()); + } + if self.voice_owner_thread_id().is_some() { + return Err("End the voice call before switching.".into()); + } + let displayed_thread = self.current_displayed_thread_id(); + let outgoing_slot = backends.displayed; + if let Some(thread_id) = displayed_thread + && self.active_turn_id_for_thread(thread_id).await.is_some() + { + return Err("Wait for the current turn to finish before switching.".into()); + } + let mut settings = backends + .settings + .get(&owner) + .cloned() + .ok_or("Connection settings are unavailable.")?; + let preference = if matches!(session, NewBackendSession::CarryDraft) + && let Some(key) = backends.preference_key(slot) + { + self.backend_preference_context + .scope() + .await + .map(|(project, user)| (project, user, key.to_owned())) + } else { + None + }; + let user_config_path = self.local_settings.user_config_path.clone(); + let (name, draft) = match session { + NewBackendSession::Fresh { name } => (name, None), + NewBackendSession::CarryDraft => ( + self.chat_widget.thread_name(), + Some(self.chat_widget.composer_draft_snapshot()), + ), + }; + let destination = backends.session_at_mut(slot); + let bootstrap = destination + .bootstrap_for_new_thread(&settings.config) + .await + .map_err(|error| format!("Failed to prepare the new conversation: {error}"))?; + let destination_cwd = settings.config.cwd.to_path_buf(); + if let Err(control) = self + .confirm_directory_trust_for_target( + tui, + destination, + &settings.target, + &settings.local_settings, + &mut settings.config, + &destination_cwd, + crate::onboarding::DirectoryTrustOptions { + cancel: displayed_thread + .map(|_| crate::onboarding::TrustCancelAction::CurrentTask), + ..Default::default() + }, + /*startup_draft*/ None, + ) + .await + { + if matches!(control, AppRunControl::Exit(_)) { + self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate)); + } + return Ok(()); + } + let (config, local_settings) = self + .load_new_session_config_from(destination, &settings, destination_cwd) + .await + .map_err(|error| format!("Failed to read new session defaults: {error}"))?; + let started = destination + .start_thread_with_session_start_source( + &local_settings, + &config, + /*session_start_source*/ None, + /*remote_cwd_override*/ None, + /*selected_profile*/ None, + ) + .await + .map_err(|error| format!("Failed to start the new conversation: {error}"))?; + // Clear before switching so a terminal error leaves the outgoing conversation usable. + tui.clear_for_thread_switch() + .map_err(|error| format!("Failed to open the new conversation: {error}"))?; + let destination_thread = started.session.thread_id; + self.switch_backend_context( + backends, + slot, + started.session.thread_id, + settings.target, + config.cwd.to_path_buf(), + bootstrap, + ) + .await; + let destination = backends.session_at_mut(slot); + self.adopt_fresh_session( + tui, + destination, + PreparedFreshSession { + started, + config, + local_settings, + }, + ThreadAttachPresentation::FreshAfterClear, + /*initial_user_message*/ None, + name, + ) + .await; + backends.displayed = slot; + if !backends.session_at_mut(slot).uses_remote_workspace() + && self.current_displayed_thread_id() == Some(destination_thread) + { + let cwd = self.config.cwd.to_path_buf(); + self.review_folder_hooks_after_attach(backends.session_at_mut(slot), cwd) + .await; + } + tui.set_notification_settings( + self.local_settings.tui.notification_settings.method, + self.local_settings.tui.notification_settings.condition, + ); + if let Some(draft) = draft { + self.chat_widget.restore_startup_draft(draft); + if let Some(thread_id) = displayed_thread + && self.current_displayed_thread_id() == Some(destination_thread) + && self + .agents_overview + .blank_sessions + .remove(&thread_id) + .is_some() + { + self.agents_overview.input_states.remove(&thread_id); + self.remove_agents_overview_thread(thread_id); + if let Err(error) = backends + .session_at_mut(outgoing_slot) + .thread_unsubscribe(thread_id) + .await + { + tracing::warn!(%error, "failed to unsubscribe replaced blank thread {thread_id}"); + } + self.discard_thread_local_state(thread_id).await; + } + } + if self.current_displayed_thread_id() == Some(destination_thread) + && let Some((project, user, key)) = preference + { + let result = ConfigEditsBuilder::for_config_path(user_config_path.as_path()) + .with_edits([crate::legacy_core::config::edit::ConfigEdit::SetPath { + segments: vec![ + "tui".into(), + "default_hosts".into(), + project.clone(), + user.clone(), + ], + value: toml_edit::value(key.clone()), + }]) + .apply() + .await; + match result { + Ok(()) => { + for settings in std::iter::once(&mut self.local_settings).chain( + backends + .settings + .values_mut() + .map(|settings| &mut settings.local_settings), + ) { + settings + .tui + .default_hosts + .entry(project.clone()) + .or_default() + .insert(user.clone(), key.clone()); + } + } + Err(error) => self.chat_widget.add_error_message(format!( + "Switched, but could not save the default for this project: {error}" + )), + } + } + tui.frame_requester().schedule_frame(); + Ok(()) + } + + // Both create and resume call this after the destination thread is ready. + async fn switch_backend_context( + &mut self, + backends: &mut BackendSessions, + destination_slot: SessionSlot, + thread_id: ThreadId, + target: AppServerTarget, + cwd: PathBuf, + bootstrap: AppServerBootstrap, + ) { + let outgoing = backends.displayed_owner(); + self.capture_session_inputs().await; + for channel in self.thread_event_channels.values_mut() { + channel.backend_id.get_or_insert(outgoing); + } + backends.settings.insert(outgoing, self.backend_settings()); + self.detach_current_thread_for_navigation( + backends.session_at_mut(backends.displayed), + Some(thread_id), + ) + .await; + let destination = backends.session_at_mut(destination_slot); + self.store_active_thread_receiver().await; + self.clear_active_thread().await; + self.agents_overview.pending_usage = None; + self.agents_overview.usage_disabled = false; + self.app_event_tx = AppEventSender::new(self.app_event_tx.app_event_tx.clone()); + self.app_server_target = target; + self.reconnect = Default::default(); + if let Ok(mut state) = self.agents_overview.view_state.lock() { + state.connection_notice = None; + } + self.rate_limit_hard_stop_generation = self.rate_limit_hard_stop_generation.wrapping_add(1); + self.rate_limit_refresh_state.invalidate_recovery(); + self.account_email_request_id = None; + self.file_search.on_user_query(String::new()); + self.bind_backend_workspace(destination, cwd); + self.model_catalog = Arc::new( + ModelCatalog::new(bootstrap.available_models) + .with_collaboration_modes(bootstrap.collaboration_modes), + ); + self.feedback_audience = bootstrap.feedback_audience; + self.chat_widget.requires_openai_auth = bootstrap.requires_openai_auth; + self.chat_widget.invalidate_security_setup(); + self.chat_widget.update_account_state( + bootstrap.status_account_display, + bootstrap.plan_type, + bootstrap.has_chatgpt_account, + matches!(bootstrap.auth_mode, Some(TelemetryAuthMode::Chatgpt)), + ); + } + pub(super) fn resume_backend_session<'a>( &'a mut self, tui: &'a mut tui::Tui, @@ -33,20 +375,19 @@ impl App { && self.active_turn_id_for_thread(thread_id).await.is_some() { self.add_session_picker_error( - "Wait for the current turn to finish before switching backends.".into(), + "Wait for the current turn to finish before switching.".into(), ); return Ok(AppRunControl::Continue); } let Some(settings) = backends.settings.get(&owner).cloned() else { self.add_session_picker_error( - "This backend has no connection settings. Open the session picker again." - .into(), + "Connection settings are unavailable. Open the session picker again.".into(), ); return Ok(AppRunControl::Continue); }; let Some(destination_slot) = backends.listening_slot(owner) else { self.add_session_picker_error( - "This session's backend is unavailable. Open the session picker again.".into(), + "The connection for this conversation is unavailable. Open the session picker again.".into(), ); return Ok(AppRunControl::Continue); }; @@ -54,7 +395,7 @@ impl App { let bootstrap = match destination.bootstrap(&settings.config).await { Ok(bootstrap) => bootstrap, Err(error) => { - let message = format!("Failed to prepare session backend: {error}"); + let message = format!("Failed to prepare the conversation: {error}"); self.add_session_picker_error(message); return Ok(AppRunControl::Continue); } @@ -66,46 +407,16 @@ impl App { Ok(prepared) => prepared, Err(control) => return Ok(control), }; - self.capture_session_inputs().await; - for channel in self.thread_event_channels.values_mut() { - channel.backend_id.get_or_insert(outgoing); - } - backends.settings.insert(outgoing, self.backend_settings()); - self.detach_current_thread_for_navigation( - backends.session_at_mut(displayed_session), - Some(target.thread_id), + self.switch_backend_context( + backends, + destination_slot, + target.thread_id, + settings.target, + prepared.config.cwd.to_path_buf(), + bootstrap, ) .await; let destination = backends.session_at_mut(destination_slot); - self.store_active_thread_receiver().await; - self.clear_active_thread().await; - self.agents_overview.pending_usage = None; - self.agents_overview.usage_disabled = false; - self.app_event_tx = AppEventSender::new(self.app_event_tx.app_event_tx.clone()); - self.app_server_target = settings.target; - self.reconnect = Default::default(); - if let Ok(mut state) = self.agents_overview.view_state.lock() { - state.connection_notice = None; - } - self.rate_limit_hard_stop_generation = - self.rate_limit_hard_stop_generation.wrapping_add(1); - self.rate_limit_refresh_state.invalidate_recovery(); - self.account_email_request_id = None; - self.file_search.on_user_query(String::new()); - self.bind_backend_workspace(destination, prepared.config.cwd.to_path_buf()); - self.model_catalog = Arc::new( - ModelCatalog::new(bootstrap.available_models) - .with_collaboration_modes(bootstrap.collaboration_modes), - ); - self.feedback_audience = bootstrap.feedback_audience; - self.chat_widget.requires_openai_auth = bootstrap.requires_openai_auth; - self.chat_widget.invalidate_security_setup(); - self.chat_widget.update_account_state( - bootstrap.status_account_display, - bootstrap.plan_type, - bootstrap.has_chatgpt_account, - matches!(bootstrap.auth_mode, Some(TelemetryAuthMode::Chatgpt)), - ); let thread_id = target.thread_id; if let Some(input) = self.agents_overview.input_states.get_mut(&thread_id) { input.provider_error = None; diff --git a/codex-rs/tui/src/app/backend_sessions.rs b/codex-rs/tui/src/app/backend_sessions.rs index f4b58c4d3d..a6569dcdf3 100644 --- a/codex-rs/tui/src/app/backend_sessions.rs +++ b/codex-rs/tui/src/app/backend_sessions.rs @@ -45,6 +45,8 @@ impl InitialBackendSessions { } struct SessionEntry { + // Stable, nonsecret name supplied by the registrar for this logical app-server. + preference_key: Option, session: AppServerSession, listening: bool, pending_requests: VecDeque, @@ -84,6 +86,7 @@ impl BackendSessions { pub(super) fn insert(&mut self, session: AppServerSession) { assert!(self.session(session.backend_id()).is_none()); self.entries.push(SessionEntry { + preference_key: None, session, listening: true, pending_requests: VecDeque::new(), @@ -118,25 +121,51 @@ impl BackendSessions { .collect() } - #[expect(dead_code, reason = "used by the next stack stage")] pub(super) fn displayed_name(&self) -> Option { let slot = self.displayed.0; self.entries[slot].listening.then(|| match slot { 0 => "Local".to_string(), - index => format!("Backend {}", index + 1), + index => format!("Connection {}", index + 1), }) } - #[expect(dead_code, reason = "used by the next stack stage")] - pub(super) fn first_listening_backend(&self) -> Option { - self.entries - .iter() - .find(|entry| entry.listening) + pub(super) fn set_preference_key(&mut self, owner: BackendId, key: &str) { + let slot = self.slot(owner).expect("registered app-server"); + assert!( + !key.is_empty() + && key + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) + ); + assert!( + self.entries + .iter() + .all(|entry| entry.preference_key.as_deref() != Some(key)) + ); + assert!(self.preference_key(slot).is_none()); + self.entries[slot.0].preference_key = Some(key.to_owned()); + } + + pub(super) fn preference_key(&self, slot: SessionSlot) -> Option<&str> { + self.entries[slot.0].preference_key.as_deref() + } + + pub(super) fn preferred_listening_backend( + &self, + preference: Option<&str>, + ) -> Option { + let mut listening = self.entries.iter().filter(|entry| entry.listening); + let preferred = preference.and_then(|key| { + listening + .clone() + .find(|entry| entry.preference_key.as_deref() == Some(key)) + }); + preferred + .or_else(|| listening.next()) .map(|entry| entry.session.backend_id()) } /// Cycle through listening connections in registration order, excluding the current one. - #[cfg_attr(not(test), expect(dead_code, reason = "used by the next stack stage"))] pub(super) fn next_listening_backend(&self, current: BackendId) -> Option { let index = self.slot(current)?.0; self.entries[index + 1..] diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index aa861b8da4..0d05f9a774 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -212,7 +212,15 @@ impl App { } AppEvent::PluginMentionsLoaded { ref cwd, .. } if cwds_differ(cwd, self.config.cwd.as_path()) => {} - AppEvent::NewSession { name } => { + AppEvent::ChooseNewSessionCheckout { name } + if self.chat_widget.managed_worktree_available() => + { + self.chat_widget + .open_session_checkout_picker(crate::app_event::ManagedWorktreeMode::New, name); + } + AppEvent::ChooseNewSessionCheckout { name } + | AppEvent::NewSession { name } + | AppEvent::NewSessionInCurrentWorktree { name } => { self.start_fresh_session( tui, app_server, /*session_start_source*/ None, /*initial_user_message*/ None, name, @@ -599,7 +607,7 @@ impl App { Err(err) => return Err(err), } } - AppEvent::ResumeBackendSession(_) => { + AppEvent::ResumeBackendSession(_) | AppEvent::CycleNewConversationBackend => { unreachable!("backend selection is handled by the session manager") } AppEvent::ArchiveCurrentThread => { diff --git a/codex-rs/tui/src/app/new_session.rs b/codex-rs/tui/src/app/new_session.rs index e6a1ade871..21e621cf7a 100644 --- a/codex-rs/tui/src/app/new_session.rs +++ b/codex-rs/tui/src/app/new_session.rs @@ -2,6 +2,7 @@ //! //! Build the replacement configuration without replacing the active task's settings. +use super::resume_preparation::BackendSettings; use super::*; use codex_config::ConfigLayerSource; use codex_protocol::config_types::ServiceTier; @@ -64,7 +65,18 @@ impl App { &mut self, app_server: &AppServerSession, ) -> Result<(Config, crate::local_settings::LocalSettings)> { + let settings = self.backend_settings(); let cwd = self.chat_widget.config_ref().cwd.to_path_buf(); + self.load_new_session_config_from(app_server, &settings, cwd) + .await + } + + pub(super) async fn load_new_session_config_from( + &mut self, + app_server: &AppServerSession, + settings: &BackendSettings, + cwd: PathBuf, + ) -> Result<(Config, crate::local_settings::LocalSettings)> { let defaults_cwd = match app_server.thread_params_mode() { crate::app_server_session::ThreadParamsMode::Embedded => cwd.as_path(), crate::app_server_session::ThreadParamsMode::Remote @@ -78,14 +90,17 @@ impl App { ) .await?; // Stage local preferences and permission carryover without changing the active task. - let (mut config, local_settings) = match self.rebuild_config_for_cwd(cwd).await { + let (mut config, local_settings) = match self + .rebuild_config_for_cwd_from(&settings.config, cwd) + .await + { Ok(config) => { - let settings = self.local_settings.reloaded(&config); - (config, settings) + let local_settings = settings.local_settings.reloaded(&config); + (config, local_settings) } Err(err) => { tracing::warn!(%err, "failed to refresh local settings before a new thread"); - (self.config.clone(), self.local_settings.clone()) + (settings.config.clone(), settings.local_settings.clone()) } }; if let Some(defaults) = defaults.as_ref() { @@ -97,7 +112,11 @@ impl App { defaults, ); } - self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::ExplicitOnly)?; + self.apply_runtime_policy_overrides_with_reviewer( + &mut config, + RuntimePolicyOverrideScope::ExplicitOnly, + settings.config.approvals_reviewer, + )?; if let Some(defaults) = defaults.as_ref() { overlay_new_session_defaults( &mut config, @@ -106,6 +125,12 @@ impl App { &self.harness_overrides, ); } + apply_managed_new_thread_defaults( + &mut config, + app_server.managed_new_thread_defaults(), + &self.cli_kv_overrides, + &self.harness_overrides, + ); Ok((config, local_settings)) } } diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index 225b8f25b7..835db96986 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -239,7 +239,7 @@ impl App { clippy::too_many_arguments, reason = "The destination target is explicit alongside the existing trust interaction inputs." )] - async fn confirm_directory_trust_for_target( + pub(super) async fn confirm_directory_trust_for_target( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 1c2ee349eb..01cf76b484 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -20,6 +20,8 @@ use std::collections::HashSet; pub(super) enum ThreadAttachPresentation { /// A primary thread created by thread/start, without inherited history. Fresh, + /// A fresh thread whose terminal clear succeeded before its backend was adopted. + FreshAfterClear, /// A fresh thread whose startup composer is already visible. FreshWithDraft, SessionLineage, @@ -1107,7 +1109,7 @@ impl App { // Start a fresh in-memory session while preserving resumability via persisted rollout // history. If an initial message is provided, `enqueue_primary_thread_session` suppresses it // until the new session is configured and any replayed turns have been rendered. - let (mut config, local_settings) = match self.load_new_session_config(app_server).await { + let (config, local_settings) = match self.load_new_session_config(app_server).await { Ok(config) => config, Err(err) => { if let Some(message) = initial_user_message { @@ -1119,12 +1121,6 @@ impl App { return; } }; - apply_managed_new_thread_defaults( - &mut config, - app_server.managed_new_thread_defaults(), - &self.cli_kv_overrides, - &self.harness_overrides, - ); match app_server .start_thread_with_session_start_source( &self.local_settings, @@ -1251,13 +1247,19 @@ impl App { self.active_thread_rx = Some(receiver); } self.store_active_thread_receiver().await; - if matches!(presentation, ThreadAttachPresentation::FreshWithDraft) { - self.reset_transcript_state_after_clear(); - tui.clear_pending_history_lines(); - tui.defer_thread_switch_clear(); - } else { - self.reset_for_thread_switch(tui)?; - } + self.reset_transcript_state_after_clear(); + tui.clear_pending_history_lines(); + let presentation = match presentation { + ThreadAttachPresentation::FreshAfterClear => ThreadAttachPresentation::Fresh, + ThreadAttachPresentation::FreshWithDraft => { + tui.defer_thread_switch_clear(); + presentation + } + ThreadAttachPresentation::Fresh | ThreadAttachPresentation::SessionLineage => { + tui.clear_for_thread_switch()?; + presentation + } + }; self.pending_thread_switch_resets += 1; self.app_event_tx.send( if matches!(presentation, ThreadAttachPresentation::FreshWithDraft) { diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 6fc0ac5729..e3e3108b1a 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -5,6 +5,7 @@ //! Queued resume history replaces the provisional loading frame only when it is ready to render. //! Explicit local launch permissions remain runtime overrides across new sessions and reconnects. +use super::backend_selection::NewBackendSession; use super::reconnect::ReconnectState; use super::startup_bootstrap::bootstrap_server_owned_start; use super::startup_bootstrap::uses_server_owned_fresh_bootstrap; @@ -242,6 +243,7 @@ impl App { initial_backends: super::backend_sessions::InitialBackendSessions, mut config: Config, launch_cwd: PathBuf, + backend_preference_context: backend_selection::BackendPreferenceContext, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, @@ -984,6 +986,7 @@ See the Codex keymap documentation for supported actions and examples." app_event_tx, chat_widget, workspace_command_runner: Some(workspace_command_runner), + backend_preference_context, config, local_settings, launch_cwd, @@ -1292,6 +1295,12 @@ See the Codex keymap documentation for supported actions and examples." let mut backends = super::backend_sessions::BackendSessions::from_initial(app_server, other_backends); + if matches!( + app.app_server_target, + AppServerTarget::Embedded | AppServerTarget::LocalDaemon { .. } + ) { + backends.set_preference_key(backends.displayed_owner(), "local"); + } let exit_reason_result = if let Some(exit_reason) = pre_loop_exit_reason { Ok(exit_reason) @@ -1299,6 +1308,14 @@ See the Codex keymap documentation for supported actions and examples." loop { let displayed_session = backends.displayed; let displayed_owner = backends.displayed_owner(); + let multi_backend = app.config.features.enabled(Feature::TuiMultiBackend); + let next_backend = if multi_backend && app.can_cycle_conversation_backend() { + backends.next_listening_backend(displayed_owner) + } else { + None + }; + let current_label = next_backend.and_then(|_| backends.displayed_name()); + app.chat_widget.set_new_backend_control(current_label); if !backends.is_listening(displayed_owner) && !app.pending_startup_thread_start && !app.reconnect.offline @@ -1452,6 +1469,22 @@ See the Codex keymap documentation for supported actions and examples." AppRunControl::Continue } Some(event) = app_event_rx.recv() => { + let switch_backend = match &event { + AppEvent::ChooseNewSessionCheckout { name } | AppEvent::NewSession { name } + if multi_backend => app.other_new_session_backend(&backends).await + .map(|owner| (owner, NewBackendSession::Fresh { name: name.clone() })), + AppEvent::CycleNewConversationBackend => { + let Some(owner) = next_backend else { continue }; + Some((owner, NewBackendSession::CarryDraft)) + } + _ => None, + }; + if let Some((owner, session)) = switch_backend { + if let Err(message) = Box::pin(app.start_new_session_on_other_backend(tui, &mut backends, owner, session)).await { + app.chat_widget.add_error_message(message); + } + continue; + } if let AppEvent::ResumeBackendSession(target) = event { match app.resume_backend_session(tui, &mut backends, target).await { Ok(AppRunControl::Continue) => continue, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 23967605c1..7b60d4b6d6 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -44,6 +44,10 @@ pub(crate) async fn make_test_app() -> Box { app_event_tx, chat_widget, workspace_command_runner: None, + backend_preference_context: backend_selection::BackendPreferenceContext::new( + &config, + Some(config.cwd.to_path_buf()), + ), launch_cwd: config.cwd.to_path_buf(), runtime_working_directory_override: None, local_settings: crate::local_settings::LocalSettings::from(&config), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 6d6c4a51cc..b02dd087f2 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -6837,6 +6837,10 @@ async fn make_test_app() -> Box { app_event_tx, chat_widget, workspace_command_runner: None, + backend_preference_context: backend_selection::BackendPreferenceContext::new( + &config, + Some(config.cwd.to_path_buf()), + ), launch_cwd: config.cwd.to_path_buf(), runtime_working_directory_override: None, local_settings: crate::local_settings::LocalSettings::from(&config), @@ -6965,6 +6969,10 @@ pub(super) async fn make_test_app_with_channels() -> ( app_event_tx, chat_widget, workspace_command_runner: None, + backend_preference_context: backend_selection::BackendPreferenceContext::new( + &config, + Some(config.cwd.to_path_buf()), + ), launch_cwd: config.cwd.to_path_buf(), runtime_working_directory_override: None, local_settings: crate::local_settings::LocalSettings::from(&config), diff --git a/codex-rs/tui/src/app/tests/backend_sessions_tests.rs b/codex-rs/tui/src/app/tests/backend_sessions_tests.rs index 23a01e84ab..0529552e5e 100644 --- a/codex-rs/tui/src/app/tests/backend_sessions_tests.rs +++ b/codex-rs/tui/src/app/tests/backend_sessions_tests.rs @@ -47,6 +47,7 @@ async fn backend_sessions_parked_request_does_not_hide_disconnect() -> Result<() let mut backends = BackendSessions::new(displayed); assert_eq!(backends.next_listening_backend(displayed_id), None); backends.insert(background); + backends.set_preference_key(background_id, "remote.work"); let initial = backends.displayed; let thread_id = ThreadId::new(); @@ -157,6 +158,10 @@ async fn backend_sessions_parked_request_does_not_hide_disconnect() -> Result<() assert_eq!(owner, background_id); assert!(closed.is_none()); assert!(!backends.is_listening(background_id)); + assert_eq!( + backends.preferred_listening_backend(Some("remote.work")), + Some(displayed_id) + ); Ok(()) } @@ -188,6 +193,22 @@ async fn backend_sessions_poll_three_sources_and_isolate_closed_or_paused_stream } let mut sessions = sessions.into_iter(); let mut backends = BackendSessions::from_initial(sessions.next().unwrap(), sessions.collect()); + backends.set_preference_key(owners[2], "remote.work"); + backends.set_preference_key(owners[0], "local"); + backends.set_preference_key(owners[1], "cloud"); + assert_eq!( + backends.preferred_listening_backend(Some("remote.work")), + Some(owners[2]) + ); + assert_eq!( + backends.preferred_listening_backend(Some("cloud")), + Some(owners[1]) + ); + assert_eq!( + backends.preferred_listening_backend(Some("missing")), + Some(owners[0]) + ); + assert_eq!( owners .iter() diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 447a3c5b4f..991d5efcc4 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -1,4 +1,5 @@ use super::App; +use super::AppEvent; use super::AppServerSession; use super::Result; use super::RuntimeKeymap; @@ -67,6 +68,61 @@ fn ctrl(ch: char) -> KeyEvent { KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL) } +#[tokio::test] +async fn empty_thread_backend_chord_preserves_draft_until_first_submission() -> Result<()> { + let (mut app, mut events, _operations) = make_test_app_with_channels().await; + let session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()); + app.chat_widget.handle_thread_session(session); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget + .apply_external_edit("first draft".to_string()); + app.chat_widget + .set_new_backend_control(Some("Cloud".to_string())); + let screen = render_bottom_popup(&app.chat_widget, 80); + insta::assert_snapshot!(screen.lines().find(|line| line.trim_start().starts_with("Cloud")).unwrap().trim(), @"Cloud ⌃x b to cycle"); + + // Cursor movement remains available while the backend selector is visible. + let key = app.route_key_chord_event(&mut tui, ctrl('b')).unwrap(); + app.chat_widget.handle_key_event(key); + app.chat_widget.handle_paste("!".to_string()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "first draf!t"); + + assert_eq!(app.route_key_chord_event(&mut tui, ctrl('x')), None); + let key = app + .route_key_chord_event(&mut tui, KeyCode::Char('b').into()) + .expect("backend chord dispatch"); + app.chat_widget.handle_key_event(key); + assert!( + std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CycleNewConversationBackend)) + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), "first draf!t"); + + // A single-key remap takes precedence over cursor movement in the same draft. + let config = toml::from_str("[global]\nchange_host = 'ctrl-b'")?; + app.keymap = RuntimeKeymap::from_config(&config).unwrap(); + app.chat_widget.apply_keymap_update(config, &app.keymap); + app.chat_widget.handle_key_event(ctrl('b')); + assert!( + std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CycleNewConversationBackend)) + ); + + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!( + !render_bottom_popup(&app.chat_widget, 80) + .lines() + .any(|line| line.trim_start().starts_with("Cloud")) + ); + + app.chat_widget.handle_key_event(ctrl('b')); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CycleNewConversationBackend)) + ); + Ok(()) +} + #[tokio::test] async fn vim_buffer_jumps_route_default_chords_in_normal_and_operator_contexts() -> Result<()> { for (input, command, expected) in [ diff --git a/codex-rs/tui/src/app/tests/new_session_tests.rs b/codex-rs/tui/src/app/tests/new_session_tests.rs index 6ac99972f7..59108b0927 100644 --- a/codex-rs/tui/src/app/tests/new_session_tests.rs +++ b/codex-rs/tui/src/app/tests/new_session_tests.rs @@ -452,10 +452,80 @@ async fn replacement_failure_keeps_current_task_and_restores_input() -> Result<( .join("\n"); if fail_read { insta::assert_snapshot!(errors, @"■ Failed to read new session defaults: config/read failed in TUI"); + server.shutdown().await?; } else { insta::assert_snapshot!(errors, @"■ Failed to start a fresh session through the app server: thread/start failed during TUI bootstrap: thread/start failed: replacement unavailable (code -32603)"); + + let source = crate::start_embedded_app_server_for_picker(&app.config).await?; + let source_id = source.backend_id(); + let destination = server.backend_id(); + let mut backends = BackendSessions::from_initial(source, vec![server]); + let destination_cwd = tempdir()?; + std::fs::create_dir(destination_cwd.path().join(".codex"))?; + std::fs::write( + destination_cwd.path().join(".codex/hooks.json"), + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"x"}]}]}}"#, + )?; + // Model a destination whose trust was saved before thread/start failed. + crate::legacy_core::config::set_project_trust_level( + home.path(), + destination_cwd.path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(std::io::Error::other)?; + let mut settings = app.backend_settings(); + settings.config.cwd = destination_cwd.path().to_path_buf().abs(); + backends.settings.insert(destination, settings.clone()); + backends.set_preference_key(destination, "remote.work"); + let preference_path = app.local_settings.user_config_path.clone(); + let original_preferences = std::fs::read(&preference_path).ok(); + let error = app + .start_new_session_on_other_backend( + &mut tui, + &mut backends, + destination, + crate::app::backend_selection::NewBackendSession::CarryDraft, + ) + .await + .unwrap_err(); + assert!(error.contains("replacement unavailable")); + assert_eq!(std::fs::read(&preference_path).ok(), original_preferences); + assert_eq!(backends.displayed_owner(), source_id); + assert_eq!(app.config, original_config); + assert_eq!(app.active_thread_id, Some(current)); + assert!(app.active_thread_rx.is_some()); + assert_eq!(app.chat_widget.thread_id(), Some(current)); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "keep this request" + ); + let (retry, retry_requests, retry_proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let retry_id = retry.backend_id(); + backends.insert(retry); + backends.settings.insert(retry_id, settings); + app.start_new_session_on_other_backend( + &mut tui, + &mut backends, + retry_id, + crate::app::backend_selection::NewBackendSession::CarryDraft, + ) + .await + .expect("retry switching to the trusted destination"); + assert_eq!(backends.displayed_owner(), retry_id); + assert_eq!(recorded_params(&retry_requests, "thread/start").len(), 1); + assert_eq!( + recorded_params(&retry_requests, "hooks/list")[0]["cwds"], + serde_json::json!([destination_cwd.path()]) + ); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("SessionStart")); + backends.shutdown().await?; + retry_proxy.await??; } - server.shutdown().await?; proxy.await??; } Ok(()) @@ -465,7 +535,7 @@ async fn replacement_failure_keeps_current_task_and_restores_input() -> Result<( async fn backend_handoff_errors_are_snapshotted() -> Result<()> { let (mut app, mut events, _ops) = make_test_app_with_channels().await; let source = crate::start_embedded_app_server_for_picker(&app.config).await?; - let (destination, _, destination_proxy) = start_recording_app_server_with_history( + let (destination, requests, destination_proxy) = start_recording_app_server_with_history( &app.config, HistoryCapabilities::ConfigReadFails, /*blocked_thread_list*/ None, @@ -487,14 +557,27 @@ async fn backend_handoff_errors_are_snapshotted() -> Result<()> { app.resume_backend_session(&mut tui, &mut backends, target.clone()) .await?; - insta::assert_snapshot!(next_history_message(&mut events), @"■ This backend has no connection settings. Open the session picker again."); + insta::assert_snapshot!(next_history_message(&mut events), @"■ Connection settings are unavailable. Open the session picker again."); backends .settings .insert(destination_id, app.backend_settings()); app.resume_backend_session(&mut tui, &mut backends, target) .await?; - insta::assert_snapshot!(next_history_message(&mut events), @"■ Unable to check folder trust: config/read failed while checking remote project trust"); + let trust_error = next_history_message(&mut events); + insta::assert_snapshot!(trust_error, @"■ Unable to check folder trust: config/read failed while checking remote project trust"); + + // Starting a fresh thread must observe the same destination trust check as resuming one. + app.start_new_session_on_other_backend( + &mut tui, + &mut backends, + destination_id, + crate::app::backend_selection::NewBackendSession::Fresh { name: None }, + ) + .await + .expect("trust failure is shown in the conversation"); + assert_eq!(next_history_message(&mut events), trust_error); + assert!(recorded_params(&requests, "thread/start").is_empty()); backends.shutdown().await?; destination_proxy.await??; @@ -797,3 +880,49 @@ async fn provider_continuation_starts_current_provider_and_drafts_saved_referenc } Ok(()) } + +#[tokio::test] +async fn backend_cycling_waits_for_foreground_and_parked_voice_calls() -> Result<()> { + for parked in [false, true] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let source = crate::start_embedded_app_server_for_picker(&app.config).await?; + let destination = crate::start_embedded_app_server_for_picker(&app.config).await?; + let source_id = source.backend_id(); + let destination_id = destination.backend_id(); + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, app.config.cwd.to_path_buf()), + Vec::new(), + ) + .await?; + assert!(app.can_cycle_conversation_backend()); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id); + if parked { + let (visible, _, _, _) = + crate::chatwidget::tests::make_chatwidget_manual_with_sender().await; + app.background_voice = Some(Box::new(std::mem::replace(&mut app.chat_widget, visible))); + } + app.chat_widget + .restore_user_message_to_composer("keep draft".into()); + assert!(!app.can_cycle_conversation_backend()); + let mut backends = BackendSessions::from_initial(source, vec![destination]); + let mut tui = crate::tui::test_support::make_test_tui()?; + let error = app + .start_new_session_on_other_backend( + &mut tui, + &mut backends, + destination_id, + crate::app::backend_selection::NewBackendSession::CarryDraft, + ) + .await + .unwrap_err(); + insta::allow_duplicates! { + insta::assert_snapshot!(error, @"End the voice call before switching."); + } + assert_eq!(backends.displayed_owner(), source_id); + assert_eq!(app.voice_owner_thread_id(), Some(thread_id)); + assert_eq!(app.chat_widget.composer_text_with_pending(), "keep draft"); + backends.shutdown().await?; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 53563c33e9..3d41d94e16 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -4663,7 +4663,7 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook } #[test] -fn fresh_session_applies_requested_name() -> Result<()> { +fn fresh_session_keeps_its_name_and_draft_when_cycling_backends() -> Result<()> { const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; std::thread::Builder::new() @@ -4678,6 +4678,43 @@ fn fresh_session_applies_requested_name() -> Result<()> { let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + app.config.cli_auth_credentials_store_mode = codex_login::AuthCredentialsStoreMode::File; + app.local_settings.user_config_path = codex_home.path().join("config.toml").abs(); + let local_project = tempdir()?; + std::fs::create_dir(local_project.path().join(".git"))?; + std::fs::write(local_project.path().join(".git/HEAD"), "ref: refs/heads/main\n")?; + app.launch_cwd = local_project.path().to_path_buf(); + app.config.cwd = local_project.path().abs(); + app.backend_preference_context = + super::super::backend_selection::BackendPreferenceContext::new(&app.config, Some(app.launch_cwd.clone())); + let write_user = |user: &str| { + app_test_support::write_chatgpt_auth( + codex_home.path(), + app_test_support::ChatGptAuthFixture::new("test-token") + .chatgpt_user_id(user) + .plan_type("plus"), + codex_login::AuthCredentialsStoreMode::File, + ) + .expect("write preference user") + }; + write_user("user-a"); + let project_key = codex_config::loader::project_trust_key(local_project.path()); + let mut expected_defaults = std::collections::BTreeMap::from([ + (project_key.clone(), std::collections::BTreeMap::from([ + ("chatgpt:user-b".to_string(), "local".to_string()), + ])), + ("other-project".to_string(), std::collections::BTreeMap::from([ + ("chatgpt:user-a".to_string(), "remote.other".to_string()), + ])), + ]); + app.local_settings.tui.default_hosts = expected_defaults.clone(); + std::fs::write( + &app.local_settings.user_config_path, + toml::to_string(&serde_json::json!({ + "projects": { (project_key.clone()): { "trust_level": "trusted" } }, + "tui": { "default_hosts": expected_defaults }, + }))?, + )?; let (mut app_server, requests, proxy) = start_recording_app_server( &app.config, /*blocked_thread_list*/ None, @@ -4685,6 +4722,10 @@ fn fresh_session_applies_requested_name() -> Result<()> { ) .await?; let mut tui = crate::tui::test_support::make_test_tui()?; + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, app.config.clone(), /*initial_user_message*/ None, + ); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); app.start_fresh_session( &mut tui, @@ -4699,6 +4740,7 @@ fn fresh_session_applies_requested_name() -> Result<()> { .chat_widget .thread_id() .expect("fresh session should have a thread id"); + assert!(app.agents_overview.blank_sessions.contains_key(&thread_id)); assert_eq!(app.chat_widget.thread_name(), Some("Add User".to_string())); assert!( requests @@ -4712,8 +4754,134 @@ fn fresh_session_applies_requested_name() -> Result<()> { .thread_read(thread_id, /*include_turns*/ false) .await?; assert_eq!(thread.name.as_deref(), Some("Add User")); + // Deliver the metadata notification that populates Command Center. + app.track_agents_overview_notification(&ServerNotification::ThreadStarted( + codex_app_server_protocol::ThreadStartedNotification { thread: thread.clone() }, + )); + let retained_thread = ThreadId::new(); + let mut retained = thread; + retained.id = retained_thread.to_string(); + retained.name = Some("Other conversation".to_string()); + app.agents_overview.threads.insert(retained_thread, Some(retained)); - app_server.shutdown().await?; + app.chat_widget.insert_str("unsent draft"); + app.agents_overview.pending_usage = Some((thread_id, uuid::Uuid::new_v4())); + app.agents_overview.usage_disabled = true; + let cwd = tempdir()?; + let mut settings = app.backend_settings(); + settings.config.cwd = cwd.path().to_path_buf().abs(); + let other = crate::start_embedded_app_server_for_picker(&settings.config).await?; + let destination = other.backend_id(); + let source = app_server.backend_id(); + let mut backends = crate::app::backend_sessions::BackendSessions::from_initial( + app_server, + vec![other], + ); + backends.settings.insert(destination, settings); + backends.set_preference_key(source, "local"); + backends.set_preference_key(destination, "remote.work"); + assert_eq!(app.other_new_session_backend(&backends).await, None); + app.handle_event( + &mut tui, + backends.session_at_mut(backends.displayed), + AppEvent::ChooseNewSessionCheckout { name: None }, + ).await?; + assert!(render_bottom_popup(&app.chat_widget, 80).contains("Use current Git worktree")); + app.chat_widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.current_displayed_thread_id(), Some(thread_id)); + assert_eq!(recorded_params(&requests, "thread/start").len(), 1); + let preference_path = app.local_settings.user_config_path.clone(); + let saved_defaults = || -> Result>> { + let saved: toml::Value = + toml::from_str(&std::fs::read_to_string(&preference_path)?)?; + Ok(saved["tui"]["default_hosts"].clone().try_into()?) + }; + assert!(app.can_cycle_conversation_backend()); + app.start_new_session_on_other_backend( + &mut tui, + &mut backends, + destination, + crate::app::backend_selection::NewBackendSession::CarryDraft, + ) + .await + .expect("switch the named conversation with its draft"); + assert_eq!(backends.displayed_owner(), destination); + expected_defaults.get_mut(&project_key).unwrap().insert( + "chatgpt:user-a".to_string(), "remote.work".to_string(), + ); + assert_eq!(saved_defaults()?, expected_defaults); + assert_eq!(app.local_settings.tui.default_hosts, expected_defaults); + write_user("user-b"); + assert_eq!(app.other_new_session_backend(&backends).await, Some(source)); + write_user("user-a"); + assert_eq!(app.other_new_session_backend(&backends).await, None); + + assert_eq!(app.agents_overview.pending_usage, None); + assert!(!app.agents_overview.usage_disabled); + assert_eq!(app.config.cwd.as_path(), cwd.path()); + assert_eq!(app.chat_widget.thread_name().as_deref(), Some("Add User")); + assert_eq!(app.chat_widget.composer_text_with_pending(), "unsent draft"); + let destination_thread = app.chat_widget.thread_id().expect("destination thread"); + assert_ne!(destination_thread, thread_id); + let view = app.agents_overview_view( + app.agents_overview.threads.values().flatten().cloned().collect(), + /*selected_thread_id*/ None, + ); + assert_eq!(view.thread_ids(), vec![retained_thread]); + insta::assert_debug_snapshot!(view.thread_ids().into_iter().map(|id| { + app.agents_overview.threads[&id].as_ref().unwrap().name.as_deref().unwrap() + }).collect::>(), @r#" + [ + "Other conversation", + ] + "#); + assert!(!app.agents_overview.blank_sessions.contains_key(&thread_id)); + assert!(!app.agents_overview.input_states.contains_key(&thread_id)); + assert!(!app.thread_event_channels.contains_key(&thread_id)); + assert_eq!( + recorded_params(&requests, "thread/unsubscribe"), + vec![serde_json::json!({"threadId": thread_id.to_string()})] + ); + app.start_new_session_on_other_backend( + &mut tui, + &mut backends, + source, + crate::app::backend_selection::NewBackendSession::CarryDraft, + ) + .await + .expect("switch back with the draft"); + assert_eq!(backends.displayed_owner(), source); + expected_defaults.get_mut(&project_key).unwrap().insert( + "chatgpt:user-a".to_string(), "local".to_string(), + ); + assert_eq!(saved_defaults()?, expected_defaults); + + assert_eq!(app.chat_widget.composer_text_with_pending(), "unsent draft"); + assert!( + !app.agents_overview + .blank_sessions + .contains_key(&destination_thread) + ); + assert!( + !app.agents_overview + .input_states + .contains_key(&destination_thread) + ); + assert!(!app.thread_event_channels.contains_key(&destination_thread)); + // An automatic fresh session may use another connection without changing + // the user's last explicit choice. + app.start_new_session_on_other_backend( + &mut tui, + &mut backends, + destination, + crate::app::backend_selection::NewBackendSession::Fresh { name: None }, + ) + .await + .expect("start without an explicit default change"); + assert_eq!(saved_defaults()?, expected_defaults); + assert_eq!(app.local_settings.tui.default_hosts, expected_defaults); + assert_eq!(app.new_thread_host_preference().await, Some("local")); + backends.shutdown().await?; proxy.await??; Ok(()) }) diff --git a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs index 5b6c38053a..bed5ce30b0 100644 --- a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs @@ -29,6 +29,14 @@ pub(super) async fn run_startup_for_test( initial_backends, config.clone(), config.cwd.to_path_buf(), + backend_selection::BackendPreferenceContext::new( + &config, + crate::backend_preference_local_cwd( + config.cwd.as_path(), + &app_server_target, + &EnvironmentManager::default_for_tests(), + ), + ), Vec::new(), ConfigOverrides::default(), LoaderOverrides::default(), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 0cddd71fa7..ccef8cf989 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1534,6 +1534,7 @@ impl App { } match presentation { ThreadAttachPresentation::Fresh + | ThreadAttachPresentation::FreshAfterClear | ThreadAttachPresentation::FreshWithDraft | ThreadAttachPresentation::SessionLineage => { self.chat_widget.handle_thread_session(session); diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index b15fb9af8a..df6aa9e4bd 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -626,19 +626,36 @@ impl App { self.keymap = keymap; self.merge_startup_warnings(tui, &history_cell::StartupWarningsCell::default()); self.restore_runtime_theme_from_config(); + if !app_server.uses_remote_workspace() + && !crate::uses_remote_workspace_or_environment( + &self.app_server_target, + &self.environment_manager, + ) + { + self.backend_preference_context.local_cwd = Some(cwd.to_path_buf()); + } self.runtime_working_directory_override = Some(cwd.to_path_buf()); if let Some(message) = project_config_warning(&self.config) { self.chat_widget.add_warning_message(message); } let message = format!("Working directory changed to: {}", cwd.display()); self.chat_widget.add_info_message(message, /*hint*/ None); + self.review_folder_hooks_after_attach(app_server, cwd.to_path_buf()) + .await; + tui.frame_requester().schedule_frame(); + } + + pub(super) async fn review_folder_hooks_after_attach( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + ) { if !self.config.bypass_hook_trust { let load_review = crate::startup_hooks_review::load_startup_hooks_review_entry; - let hooks = load_review(app_server.request_handle(), cwd.to_path_buf()).await; + let hooks = load_review(app_server.request_handle(), cwd).await; if hooks.hooks.iter().any(crate::hooks_rpc::hook_needs_review) { self.chat_widget.open_hooks_browser(hooks); } } - tui.frame_requester().schedule_frame(); } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bd7eb5ed0c..3444d65bd0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -570,6 +570,19 @@ pub(crate) enum AppEvent { name: Option, }, + /// Resolve the /new host before offering choices about the displayed checkout. + ChooseNewSessionCheckout { + name: Option, + }, + + /// Start on the current worktree's host, ignoring the saved new-thread host. + NewSessionInCurrentWorktree { + name: Option, + }, + + /// Move an empty conversation to the next connected backend. + CycleNewConversationBackend, + /// Continue a provider-locked conversation in a fresh chat using its saved history. ContinueProviderChat { thread_id: ThreadId, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3d90f8d33e..d23ccfcff7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -690,7 +690,7 @@ struct MentionCompletionTarget { } #[derive(Clone, Copy)] -enum ShellPrefixHandling { +pub(crate) enum ShellPrefixHandling { Interpret, Literal, } @@ -711,6 +711,7 @@ pub(super) struct ComposerDraft { #[derive(Clone, Debug, PartialEq)] pub(crate) struct ComposerDraftSnapshot { pub(crate) text: String, + pub(crate) shell_mode: Option, pub(crate) cursor: usize, pub(crate) text_elements: Vec, pub(crate) local_images: Vec, @@ -1598,7 +1599,7 @@ impl ChatComposer { ); } - fn set_text_content_with_shell_prefix( + pub(super) fn set_text_content_with_shell_prefix( &mut self, text: String, text_elements: Vec, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search_draft.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_draft.rs index 7557fa61d0..2a947197b0 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search_draft.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_draft.rs @@ -21,6 +21,10 @@ impl ChatComposer { attachments.reset_local_images(draft.local_image_paths, &mut textarea); ComposerDraftSnapshot { text: draft.text, + shell_mode: self + .config + .shell_commands_enabled + .then_some(draft.is_bash_mode), cursor: draft.cursor, text_elements: draft.text_elements, local_images: attachments.local_images, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7891b5eb0c..7b21db9d3a 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -272,6 +272,7 @@ pub(crate) use chat_composer::InputResult; pub(crate) use chat_composer::KillBufferSnapshot; pub(crate) use chat_composer::QueuedInputAction; pub(crate) use chat_composer::RestrictedInputMode; +pub(crate) use chat_composer::ShellPrefixHandling; pub(crate) use chat_composer_history::HistoryEntry; use crate::status_indicator_widget::StatusDetailsCapitalization; @@ -568,6 +569,10 @@ impl BottomPane { self.composer.record_pending_slash_command_history(); } + pub(super) fn keymap(&self) -> &RuntimeKeymap { + &self.keymap + } + /// Replace all bottom-pane keymap caches from one resolved runtime keymap. /// /// The bottom pane owns several input surfaces: composer, overlays, and @@ -1183,13 +1188,15 @@ impl BottomPane { text_elements: Vec, local_image_paths: Vec, mention_bindings: Vec, + shell_prefix: ShellPrefixHandling, ) { self.composer.edit_stored_draft(|composer| { - composer.set_text_content_with_mention_bindings( + composer.set_text_content_with_shell_prefix( text, text_elements, local_image_paths, mention_bindings, + shell_prefix, ); composer.move_cursor_to_end(); }); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4af0c2278e..310f1e3b92 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -712,6 +712,7 @@ pub(crate) struct ChatWidget { safety_buffering_source: UserMessageSource, /// Main chat-surface bindings resolved from `tui.keymap.chat`. chat_keymap: ChatKeymap, + new_backend_label: Option, /// Whether the preset submenu owns the next key. quick_dial_menu: bool, /// Ignore a held key after it dismisses the preset submenu. diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 6e405d201e..25cdb4e95e 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -260,6 +260,7 @@ impl ChatWidget { suppress_initial_user_message_submit: false, pending_notification: None, quit_shortcut_expires_at: None, + new_backend_label: None, quick_dial_menu: false, quick_dial_menu_key_guard: None, quit_shortcut_key: None, diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index cf5086aabc..e6dfcfad6c 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -7,6 +7,7 @@ use codex_protocol::models::local_image_label_text; use crate::bottom_pane::ComposerDraftSnapshot; use crate::bottom_pane::KillBufferSnapshot; use crate::bottom_pane::MentionUsage; +use crate::bottom_pane::ShellPrefixHandling; use super::user_messages::remap_colliding_paste_placeholders; use super::*; @@ -24,6 +25,15 @@ impl ChatWidget { self.bottom_pane.restore_kill_buffer_snapshot(snapshot); } + pub(crate) fn composer_draft_snapshot(&mut self) -> ComposerDraftSnapshot { + self.bottom_pane.flush_pending_composer_input(); + let mut draft = self.bottom_pane.composer_draft_snapshot(); + // This snapshot comes from an active host, where disabled shell commands + // also mean a leading bang was entered literally. + draft.shell_mode = Some(draft.shell_mode.unwrap_or(false)); + draft + } + /// Restore the exact draft entered before the fully initialized composer became available. pub(crate) fn restore_startup_draft(&mut self, draft: ComposerDraftSnapshot) { self.bottom_pane @@ -79,20 +89,23 @@ impl ChatWidget { .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, - )); + self.restore_composer_state_with_shell_mode( + Self::composer_state_from_user_message( + merge_user_messages(vec![existing_message, startup_message]), + pending_pastes, + ), + existing_draft.shell_mode, + ); startup_offset .saturating_add(draft.cursor) .saturating_add(cursor_adjustment) } else if existing_has_content || !startup_has_content { existing_cursor } else { - self.restore_composer_state(Self::composer_state_from_user_message( - startup_message, - draft.pending_pastes, - )); + self.restore_composer_state_with_shell_mode( + Self::composer_state_from_user_message(startup_message, draft.pending_pastes), + draft.shell_mode, + ); draft.cursor }; self.bottom_pane.set_composer_cursor(cursor); @@ -564,6 +577,14 @@ impl ChatWidget { } pub(super) fn restore_composer_state(&mut self, composer: ThreadComposerState) { + self.restore_composer_state_with_shell_mode(composer, None); + } + + fn restore_composer_state_with_shell_mode( + &mut self, + composer: ThreadComposerState, + shell_mode: Option, + ) { let ThreadComposerState { text, local_images, @@ -579,6 +600,11 @@ impl ChatWidget { text_elements, local_image_paths, mention_bindings, + if shell_mode == Some(false) { + ShellPrefixHandling::Literal + } else { + ShellPrefixHandling::Interpret + }, ); self.bottom_pane.set_composer_pending_pastes(pending_pastes); } diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 8420b4a3f7..ec3be5170a 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -75,7 +75,12 @@ impl ChatWidget { } pub(crate) fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { - self.bottom_pane.keymap_contexts() + let contexts = self.bottom_pane.keymap_contexts(); + if self.visible_new_backend_control().is_some() { + contexts.with_backend_cycling() + } else { + contexts + } } pub(crate) fn take_key_chord_reset(&mut self) -> bool { @@ -92,6 +97,35 @@ impl ChatWidget { self.handle_key_event_with_activation_key(key_event, /*activation_key*/ None) } + pub(crate) fn set_new_backend_control(&mut self, label: Option) { + if self.new_backend_label != label { + self.new_backend_label = label; + self.request_redraw(); + } + } + + pub(crate) fn can_choose_new_backend(&self) -> bool { + !self.blocks_direct_input + && !self.is_user_turn_pending_or_running() + && self.last_turn_id().is_none() + && !self.input_queue.has_queued_follow_up_messages() + && self.input_queue.startup_submission.is_none() + && self.initial_user_message.is_none() + } + + pub(super) fn visible_new_backend_control(&self) -> Option<&str> { + let label = self.new_backend_label.as_deref()?; + (self.can_choose_new_backend() + && self.bottom_pane.no_modal_or_popup_active() + && !self.fork_in_progress + && !self.shortcut_overlay_visible() + && !self.quick_dial_menu + && self.safety_recourse.is_none() + && !self.has_provider_error() + && matches!(self.external_editor_state, ExternalEditorState::Closed)) + .then_some(label) + } + pub(crate) fn handle_key_event_with_activation_key( &mut self, key_event: KeyEvent, @@ -134,6 +168,15 @@ impl ChatWidget { return KeyEventAction::None; } + let keymap = self.bottom_pane.keymap(); + if keymap.app.change_host.is_pressed(key_event) + && self.visible_new_backend_control().is_some() + { + self.app_event_tx + .send(AppEvent::CycleNewConversationBackend); + return KeyEventAction::None; + } + if self.shortcut_overlay_visible() && key_hint::plain(KeyCode::Esc).is_press(key_event) { self.bottom_pane .handle_key_event_with_activation_key(key_event, activation_key); diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs index 80c62f7e8f..0732608461 100644 --- a/codex-rs/tui/src/chatwidget/keymap_picker.rs +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -12,6 +12,7 @@ //! appear to accept a remap while some handlers still respond to the old keys. use codex_config::types::TuiKeymap; +use codex_features::Feature; use super::ChatWidget; use crate::app_event::KeymapCaptureMode; @@ -154,6 +155,7 @@ impl ChatWidget { fn keymap_action_filter(&self) -> keymap_setup::KeymapActionFilter { keymap_setup::KeymapActionFilter { fast_mode_enabled: self.fast_mode_enabled(), + multi_backend_enabled: self.config.features.enabled(Feature::TuiMultiBackend), } } diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 8ab45414e5..06764559c7 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -4,6 +4,7 @@ use super::provider_error::ProviderErrorNotice; use super::transcript::ActiveCellLayoutCache; use super::transcript::ActiveCellLayoutCacheKey; use super::*; +use crate::keymap::KeymapContext; use crate::render::RectExt; use crate::terminal_hyperlinks::HyperlinkParagraph; use crate::wrapping::RtOptions; @@ -296,6 +297,13 @@ impl ChatWidget { && !self.bottom_pane.has_active_view() { RenderableItem::Borrowed(&pending.notice) + } else if let Some(backend) = self.visible_new_backend_control() { + let mut spans = vec![" ".into(), backend.into()]; + let keymap = self.bottom_pane.keymap(); + if let Some(key) = keymap.primary_hint(KeymapContext::Global, "change_host") { + spans.extend([" ".into(), key.into(), " to cycle".dim()]); + } + RenderableItem::Owned(Box::new(Paragraph::new(Line::from(spans)))) } else { return bottom_pane; }; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 47c6985de1..81e949b919 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -996,6 +996,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { mention: "figma".to_string(), path: user_skill_path.to_string_lossy().into_owned(), }], + crate::bottom_pane::ShellPrefixHandling::Interpret, ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -1174,6 +1175,42 @@ 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_literal_and_shell_bang() { + for text in [ + "!echo literal", + "!echo literal\nsecond line", + " !echo literal", + ] { + let (mut source, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + source.bottom_pane.insert_str(&format!("x{text}")); + source.bottom_pane.set_composer_cursor(0); + source.handle_key_event(KeyEvent::from(KeyCode::Delete)); + let draft = source.composer_draft_snapshot(); + assert_eq!(draft.text, text); + assert_eq!(draft.shell_mode, Some(false)); + + let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.restore_startup_draft(draft.clone()); + assert_eq!(chat.bottom_pane.composer_draft_snapshot(), draft); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_hidden_shell_payload_is_literal(ops.try_recv(), text.trim().to_string()); + } + + let (mut source, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + source + .bottom_pane + .set_composer_text("!pwd".into(), Vec::new(), Vec::new()); + let draft = source.composer_draft_snapshot(); + assert_eq!(draft.shell_mode, Some(true)); + let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.restore_startup_draft(draft); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!(ops.try_recv(), Ok(Op::RunUserShellCommand { command }) if command == "pwd"); +} + #[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; @@ -2679,6 +2716,7 @@ async fn image_preparation_failure_restores_full_input_without_submitting() { mention: "file".into(), path: "/tmp/skills/file/SKILL.md".into(), }], + crate::bottom_pane::ShellPrefixHandling::Interpret, ); let draft = chat.bottom_pane.composer_draft_snapshot(); if oversized { diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index a7ae7d534f..b16c924ab5 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -989,6 +989,7 @@ async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer() )], Vec::new(), mention_bindings.clone(), + crate::bottom_pane::ShellPrefixHandling::Interpret, ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index dc5d14e86f..98273ce95a 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -807,6 +807,7 @@ async fn goal_slash_command_uses_plain_text_for_mentions() { mention: "figma".to_string(), path: "app://figma".to_string(), }], + crate::bottom_pane::ShellPrefixHandling::Interpret, ); chat.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); @@ -1251,6 +1252,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() { mention: "figma".to_string(), path: "app://figma".to_string(), }], + crate::bottom_pane::ShellPrefixHandling::Interpret, ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); diff --git a/codex-rs/tui/src/chatwidget/tests/startup_submission_tests.rs b/codex-rs/tui/src/chatwidget/tests/startup_submission_tests.rs index b9082189b4..bba2726c87 100644 --- a/codex-rs/tui/src/chatwidget/tests/startup_submission_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/startup_submission_tests.rs @@ -2,6 +2,22 @@ use super::*; use crate::app_event::HistoryLookupResponse; use pretty_assertions::assert_eq; +#[tokio::test] +async fn host_switch_snapshot_preserves_buffered_typing() { + for typed in ["x", "several keys"] { + let (mut chat, _events, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_disable_paste_burst(/*disabled*/ false); + chat.insert_str("draft "); + for ch in typed.chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + assert_eq!( + chat.composer_draft_snapshot().text, + format!("draft {typed}") + ); + } +} + #[tokio::test] async fn initial_prompt_recall_precedes_cross_session_history() { for submit_follow_up in [false, true] { diff --git a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs index 5c7daae9a2..a3aa559797 100644 --- a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs @@ -36,12 +36,19 @@ async fn slash_new_and_fork_offer_checkout_choices_inside_local_git_repository() assert!(popup.contains("Create new Git worktree"), "popup: {popup}"); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.set_feature_enabled(Feature::TuiMultiBackend, /*enabled*/ true); chat.bottom_pane .set_composer_text("/new named".into(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); + let Ok(AppEvent::ChooseNewSessionCheckout { name }) = rx.try_recv() else { + panic!("/new must resolve its host before offering checkout choices"); + }; + assert!(!chat.has_active_modal()); + assert_eq!(name.as_deref(), Some("named")); + chat.open_session_checkout_picker(crate::app_event::ManagedWorktreeMode::New, name); chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); - assert_matches!(rx.try_recv(), Ok(AppEvent::NewSession { name: Some(name) }) if name == "named"); + assert_matches!(rx.try_recv(), Ok(AppEvent::NewSessionInCurrentWorktree { name: Some(name) }) if name == "named"); chat.bottom_pane .set_composer_text("/fork named".into(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -51,6 +58,7 @@ async fn slash_new_and_fork_offer_checkout_choices_inside_local_git_repository() mode: crate::app_event::ManagedWorktreeMode::Fork, name: Some(name), }) if name == "named"); + chat.set_feature_enabled(Feature::TuiMultiBackend, /*enabled*/ false); chat.set_local_worktree_operations(/*enabled*/ false); chat.dispatch_command(SlashCommand::New); assert_matches!(rx.try_recv(), Ok(AppEvent::NewSession { name: None })); diff --git a/codex-rs/tui/src/chatwidget/worktree_picker.rs b/codex-rs/tui/src/chatwidget/worktree_picker.rs index d40d2a8406..dc8eaf3c7b 100644 --- a/codex-rs/tui/src/chatwidget/worktree_picker.rs +++ b/codex-rs/tui/src/chatwidget/worktree_picker.rs @@ -13,7 +13,7 @@ use crate::worktree_browser::Request; const BROWSER_VIEW_ID: &str = "managed-worktrees"; impl ChatWidget { - pub(super) fn managed_worktree_available(&self) -> bool { + pub(crate) fn managed_worktree_available(&self) -> bool { self.local_worktree_operations && get_git_repo_root(self.config.cwd.as_path()).is_some() } @@ -22,6 +22,13 @@ impl ChatWidget { mode: ManagedWorktreeMode, name: Option, ) { + if mode == ManagedWorktreeMode::New + && self.config.features.enabled(Feature::TuiMultiBackend) + { + self.app_event_tx + .send(AppEvent::ChooseNewSessionCheckout { name }); + return; + } if !self.managed_worktree_available() { match mode { ManagedWorktreeMode::New => { @@ -35,6 +42,14 @@ impl ChatWidget { return; } + self.open_session_checkout_picker(mode, name); + } + + pub(crate) fn open_session_checkout_picker( + &mut self, + mode: ManagedWorktreeMode, + name: Option, + ) { let title = match mode { ManagedWorktreeMode::New => "Where should the new conversation run?", ManagedWorktreeMode::Fork => "Where should the forked conversation run?", @@ -48,7 +63,7 @@ impl ChatWidget { description: Some("Keep using the current working directory".to_string()), actions: vec![Box::new(move |tx| match mode { ManagedWorktreeMode::New => { - tx.send(AppEvent::NewSession { + tx.send(AppEvent::NewSessionInCurrentWorktree { name: current_name.clone(), }); } diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 442213036c..b438dd6513 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -102,6 +102,8 @@ pub(crate) struct RuntimeKeymap { #[derive(Clone, Debug)] pub(crate) struct AppKeymap { + /// Change where a new conversation runs. + pub(crate) change_host: Vec, /// Open the agent command center. pub(crate) open_agents: Vec, /// Open transcript overlay. @@ -723,6 +725,11 @@ impl RuntimeKeymap { .collect(); let app = AppKeymap { + change_host: resolve_bindings( + keymap.global.change_host.as_ref(), + &defaults.app.change_host, + "tui.keymap.global.change_host", + )?, open_agents: resolve_bindings( keymap.global.open_agents.as_ref(), &defaults.app.open_agents, @@ -1697,6 +1704,7 @@ impl RuntimeKeymap { fn built_in_defaults() -> Self { Self { app: AppKeymap { + change_host: Vec::new(), open_agents: default_bindings![], open_transcript: default_bindings![ctrl(KeyCode::Char('t'))], find_transcript: default_bindings![plain(KeyCode::F(3))], @@ -2023,15 +2031,16 @@ impl RuntimeKeymap { /// backtracking, intentionally stay outside this configurable keymap. fn validate_conflicts(&self) -> Result<(), String> { for (action, bindings) in [ - ("open_presets", &self.chat.open_presets), - ("toggle_voice", &self.chat.toggle_voice), + ("global.change_host", &self.app.change_host), + ("chat.open_presets", &self.chat.open_presets), + ("chat.toggle_voice", &self.chat.toggle_voice), ( - "previous_permission_mode", + "chat.previous_permission_mode", &self.chat.previous_permission_mode, ), - ("next_permission_mode", &self.chat.next_permission_mode), - ("prompt_stack_back", &self.chat.prompt_stack_back), - ("skip_question", &self.chat.skip_question), + ("chat.next_permission_mode", &self.chat.next_permission_mode), + ("chat.prompt_stack_back", &self.chat.prompt_stack_back), + ("chat.skip_question", &self.chat.skip_question), ] { if bindings.iter().any(|binding| { let (code, modifiers) = binding.parts(); @@ -2039,12 +2048,13 @@ impl RuntimeKeymap { || matches!(code, KeyCode::Char(_)) && crate::key_hint::is_altgr(modifiers) }) { return Err(format!( - "tui.keymap.chat.{action}: printable keys are reserved for text input" + "tui.keymap.{action}: printable keys are reserved for text input" )); } } #[cfg(unix)] for (action, bindings) in [ + ("global.change_host", &self.app.change_host), ("global.open_agents", &self.app.open_agents), ("chat.open_presets", &self.chat.open_presets), ("chat.toggle_voice", &self.chat.toggle_voice), @@ -2074,6 +2084,7 @@ impl RuntimeKeymap { } let main_bindings = [ + ("change_host", self.app.change_host.as_slice()), ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), ("find_transcript", self.app.find_transcript.as_slice()), diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index cfb430b5ce..f662edf156 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -284,6 +284,7 @@ macro_rules! define_runtime_action_bindings { define_runtime_action_bindings! { "global" => Global, app, global [ + change_host, open_agents, open_transcript, find_transcript, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index cebea5f85e..2b07a95ecd 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -53,6 +53,7 @@ const VOICE_TOGGLE: u32 = 1 << 17; const RAW_KEY_CAPTURE: u32 = 1 << 18; const VOICE_TOGGLE_BLOCKED: u32 = 1 << 19; const TRANSCRIPT_ACTIONS: u32 = 1 << 20; +const BACKEND_CYCLING: u32 = 1 << 21; const TRANSCRIPT_BROWSING: u32 = 1 << 14; @@ -67,6 +68,11 @@ impl KeymapContextSet { Self(context_bit(KeymapContext::List) | context_bit(KeymapContext::Global) | ACTIVITY_FOCUS) } + /// Offer backend cycling only while the new conversation control is visible. + pub(crate) const fn with_backend_cycling(self) -> Self { + Self(self.0 | BACKEND_CYCLING) + } + /// Add only the detail-close action to an owned composer path. pub(crate) const fn with_transcript_close(self) -> Self { Self(self.0 | context_bit(KeymapContext::Pager) | TRANSCRIPT_CLOSE) @@ -152,6 +158,9 @@ impl KeymapContextSet { /// Whether this input path can dispatch the action, including focus-specific exclusions. pub(crate) fn contains_action(self, action: KeymapActionId) -> bool { + if action.context == KeymapContext::Global && action.action == "change_host" { + return self.0 & BACKEND_CYCLING != 0; + } if action.context == KeymapContext::Chat && action.action == "open_presets" { return self.contains(KeymapContext::Chat) && self.contains(KeymapContext::Global) @@ -519,6 +528,7 @@ impl KeyChordMatcher { { let label = match binding.action.action { "open_presets" if pending.started_at.is_none() => "presets", + "change_host" => "cycle", "focus_activity" => "activity", "find_transcript" => "find", "open_warnings" => "warnings", diff --git a/codex-rs/tui/src/keymap/chords/leader_defaults.rs b/codex-rs/tui/src/keymap/chords/leader_defaults.rs index 425f8ca271..f559eebef5 100644 --- a/codex-rs/tui/src/keymap/chords/leader_defaults.rs +++ b/codex-rs/tui/src/keymap/chords/leader_defaults.rs @@ -23,6 +23,13 @@ pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeym .count(), ); for (configured, context, action, completion, spec) in [ + ( + keymap.global.change_host.as_ref(), + KeymapContext::Global, + "change_host", + 'b', + "leader b", + ), ( keymap.global.select_transcript.as_ref(), KeymapContext::Global, diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index ae9c92ce36..a6741befc9 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -822,6 +822,7 @@ mod tests { fn fast_mode_action_filter() -> KeymapActionFilter { KeymapActionFilter { fast_mode_enabled: true, + ..Default::default() } } @@ -899,7 +900,10 @@ mod tests { let params = build_keymap_picker_params_with_filter( &runtime, &TuiKeymap::default(), - fast_mode_action_filter(), + KeymapActionFilter { + multi_backend_enabled: true, + fast_mode_enabled: true, + }, ); let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index a564c377b1..2d02d40ce0 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -35,11 +35,13 @@ pub(super) struct KeymapActionDescriptor { #[derive(Clone, Copy, Debug)] enum KeymapActionFeature { FastMode, + MultiBackend, } #[derive(Clone, Copy, Debug, Default)] pub(crate) struct KeymapActionFilter { pub(crate) fast_mode_enabled: bool, + pub(crate) multi_backend_enabled: bool, } impl KeymapActionDescriptor { @@ -47,6 +49,7 @@ impl KeymapActionDescriptor { match self.required_feature { None => true, Some(KeymapActionFeature::FastMode) => filter.fast_mode_enabled, + Some(KeymapActionFeature::MultiBackend) => filter.multi_backend_enabled, } } } @@ -111,6 +114,7 @@ macro_rules! define_keymap_actions { define_keymap_actions! { global "Global" { + change_host [MultiBackend] => "Change where a new conversation runs.", open_agents => "Open the agent command center.", open_transcript => "Open the transcript overlay.", find_transcript => "Find text in the full transcript.", @@ -298,6 +302,9 @@ define_keymap_actions! { /// parsed back into an action name, because underscores and casing are part of /// the stable config contract. pub(super) fn action_label(action: &str) -> String { + if action == "change_host" { + return "Change Where to Run".to_string(); + } action .split('_') .map(|word| { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9b534d23d6..1c4d24a57e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -984,6 +984,18 @@ fn uses_remote_workspace_or_environment( .is_some_and(|environment| environment.is_remote()) } +fn backend_preference_local_cwd( + config_cwd: &Path, + app_server_target: &AppServerTarget, + environment_manager: &EnvironmentManager, +) -> Option { + if uses_remote_workspace_or_environment(app_server_target, environment_manager) { + std::env::current_dir().ok() + } else { + Some(config_cwd.to_path_buf()) + } +} + #[expect( clippy::too_many_arguments, reason = "validate selection even after the picker connection is closed" @@ -1235,6 +1247,15 @@ async fn run_ratatui_app( >, startup_draft: startup_draft::StartupDraft, ) -> color_eyre::Result { + // Capture host credentials and project inputs before resume/fork reloads executor config. + let backend_preference_context = app::backend_selection::BackendPreferenceContext::new( + &initial_config, + backend_preference_local_cwd( + initial_config.cwd.as_path(), + &app_server_target, + &environment_manager, + ), + ); let backend_id = crate::backend_id::BackendId::new(); let uses_remote_workspace = app_server_target.uses_remote_workspace(); let workload_identity_selected = is_workload_identity_selected(); @@ -2221,6 +2242,7 @@ async fn run_ratatui_app( crate::app::backend_sessions::InitialBackendSessions::singleton(app_server), config, current_cwd.to_path_buf(), + backend_preference_context, cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), diff --git a/codex-rs/tui/src/lib_tests.rs b/codex-rs/tui/src/lib_tests.rs index bb5441180f..faf27865a5 100644 --- a/codex-rs/tui/src/lib_tests.rs +++ b/codex-rs/tui/src/lib_tests.rs @@ -1394,6 +1394,19 @@ async fn config_cwd_for_app_server_target_omits_cwd_for_remote_exec_server() -> ) .await; + assert_eq!( + backend_preference_local_cwd(remote_only_cwd, &target, &environment_manager), + std::env::current_dir().ok(), + ); + assert_eq!( + backend_preference_local_cwd( + remote_only_cwd, + &target, + &EnvironmentManager::default_for_tests(), + ), + Some(remote_only_cwd.to_path_buf()), + ); + let config_cwd = config_cwd_for_app_server_target( Some(remote_only_cwd), &target, diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index 5adf8671b8..d7b9e738ae 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -56,6 +56,7 @@ impl LocalSettings { config.tui_alternate_screen != codex_config::types::AltScreenMode::Never, ), tui: Tui { + default_hosts: Default::default(), notification_settings: config.tui_notifications.clone(), animations: config.animations, screen_reader_detection_done: None, @@ -158,6 +159,15 @@ impl LocalSettings { system_motion: crate::motion::MotionMode, screen_reader_default: crate::motion::MotionMode, ) { + self.tui.default_hosts = layers + .effective_user_config() + .and_then(|config| { + config + .get("tui") + .and_then(|tui| tui.get("default_hosts")) + .and_then(|value| value.clone().try_into().ok()) + }) + .unwrap_or_default(); if screen_reader_default == crate::motion::MotionMode::Reduced { // Consult the current layers so preferences edited after startup still win. self.tui.animations &= layers diff --git a/codex-rs/tui/src/local_settings_tests.rs b/codex-rs/tui/src/local_settings_tests.rs index c67c874ee0..defcdd42de 100644 --- a/codex-rs/tui/src/local_settings_tests.rs +++ b/codex-rs/tui/src/local_settings_tests.rs @@ -119,9 +119,10 @@ async fn system_motion_suppresses_animations_without_changing_saved_preferences( #[tokio::test] async fn local_load_preserves_defaults_and_resolved_overrides() -> anyhow::Result<()> { - for config_text in [ - "", - r#" + for (config_text, expected_server) in [ + ("", None), + ( + r#" [tui] animations = false show_tooltips = false @@ -135,6 +136,8 @@ right_click_paste = "off" vim_mode_default = true terminal_resize_reflow_max_rows = 0 session_picker_view = "comfortable" +[tui.default_hosts.project_a] +user_a = "remote.work" [tui.effects] shimmer = false [tui.rendering] @@ -148,9 +151,23 @@ max_bytes = 4096 [notice] fast_default_opt_out = true "#, + Some("remote.work"), + ), ] { let home = tempfile::tempdir()?; std::fs::write(home.path().join("config.toml"), config_text)?; + let project = tempfile::tempdir()?; + let project_cwd = AbsolutePathBuf::from_absolute_path(project.path())?; + std::fs::create_dir(project.path().join(".codex"))?; + std::fs::write( + project.path().join(".codex/config.toml"), + "[tui]\ntheme = 'project-theme'\n[tui.default_hosts.project_a]\nuser_a = 'project-override'\nuser_b = 'project-only'\n", + )?; + crate::legacy_core::config::set_project_trust_level( + home.path(), + project.path(), + codex_protocol::config_types::TrustLevel::Trusted, + )?; let cli_overrides = vec![ ("tui.disable_paste_burst".into(), true.into()), ("tui.right_click_paste".into(), "on".into()), @@ -163,11 +180,9 @@ fast_default_opt_out = true ]; let config = ConfigBuilder::default() .codex_home(home.path().to_path_buf()) + .fallback_cwd(Some(project.path().to_path_buf())) .strict_config(true) - .loader_overrides(LoaderOverrides { - ignore_project_config: true, - ..LoaderOverrides::without_managed_config_for_tests() - }) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .cli_overrides(cli_overrides.clone()) .build() .await?; @@ -176,13 +191,10 @@ fast_default_opt_out = true assert!(config.tui_fullscreen_transcript); let bootstrap = crate::legacy_core::config::load_config_toml_with_layer_stack( home.path(), - /*cwd*/ None, + Some(&project_cwd), cli_overrides, codex_config::ConfigLoadOptions { - loader_overrides: LoaderOverrides { - ignore_project_config: true, - ..LoaderOverrides::without_managed_config_for_tests() - }, + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), strict_config: true, ..Default::default() }, @@ -193,6 +205,24 @@ fast_default_opt_out = true LocalSettings::from(&config), ); assert_eq!(config.features.legacy_feature_usages().count(), 1); + assert_eq!(config.tui_theme.as_deref(), Some("project-theme")); + assert!( + LocalSettings::from(&config) + .tui + .default_hosts + .get("project_a") + .and_then(|users| users.get("user_b")) + .is_none() + ); + assert_eq!( + LocalSettings::from(&config) + .tui + .default_hosts + .get("project_a") + .and_then(|users| users.get("user_a")) + .map(String::as_str), + expected_server, + ); } Ok(()) } diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 56f6541022..df382e0f9f 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -144,7 +144,9 @@ fn log_inbound_app_event_with(logger: &SessionLogger, event: &AppEvent) { } match event { - AppEvent::NewSession { .. } => { + AppEvent::NewSession { .. } + | AppEvent::NewSessionInCurrentWorktree { .. } + | AppEvent::CycleNewConversationBackend => { let value = json!({ "ts": now_ts(), "dir": "to_tui", diff --git a/codex-rs/tui/src/startup_recovery_tests.rs b/codex-rs/tui/src/startup_recovery_tests.rs index 57cedd8bde..8dcc60b9f0 100644 --- a/codex-rs/tui/src/startup_recovery_tests.rs +++ b/codex-rs/tui/src/startup_recovery_tests.rs @@ -10,6 +10,7 @@ use pretty_assertions::assert_eq; fn snapshot(text: &str) -> ComposerDraftSnapshot { ComposerDraftSnapshot { text: text.to_string(), + shell_mode: None, cursor: text.len(), text_elements: Vec::new(), local_images: Vec::new(),