From 22e7856ef232fbc121f480283869f577ca8a09ba Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Wed, 17 Jun 2026 12:48:53 -0400 Subject: [PATCH] feat: add TUI login profile picker --- .../request_processors/account_processor.rs | 29 ++- codex-rs/tui/src/app.rs | 12 ++ codex-rs/tui/src/app/event_dispatch.rs | 3 + codex-rs/tui/src/app/session_lifecycle.rs | 201 ++++++++++++++++-- codex-rs/tui/src/app/test_support.rs | 3 + codex-rs/tui/src/app/tests.rs | 6 + codex-rs/tui/src/app_event.rs | 11 + codex-rs/tui/src/app_server_session.rs | 9 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 125 +++++++++++ .../src/chatwidget/tests/slash_commands.rs | 39 ++++ codex-rs/tui/src/lib.rs | 7 +- codex-rs/tui/src/slash_command.rs | 4 + 12 files changed, 421 insertions(+), 28 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index a4bbe42d6139..efed43109189 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -168,6 +168,10 @@ impl AccountRequestProcessor { } } + fn auth_storage_home(config: &Config) -> &Path { + config.auth_home.as_path() + } + async fn maybe_refresh_remote_installed_plugins_cache_for_current_config( config_manager: &ConfigManager, thread_manager: &Arc, @@ -283,7 +287,7 @@ impl AccountRequestProcessor { } match login_with_api_key( - &self.config.codex_home, + Self::auth_storage_home(&self.config), ¶ms.api_key, self.config.cli_auth_credentials_store_mode, ) { @@ -330,7 +334,7 @@ impl AccountRequestProcessor { open_browser: false, codex_streamlined_login, ..LoginServerOptions::new( - config.codex_home.to_path_buf(), + Self::auth_storage_home(config).to_path_buf(), CLIENT_ID.to_string(), config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, @@ -588,7 +592,7 @@ impl AccountRequestProcessor { } login_with_chatgpt_auth_tokens( - &self.config.codex_home, + Self::auth_storage_home(&self.config), &access_token, &chatgpt_account_id, chatgpt_plan_type.as_deref(), @@ -1016,7 +1020,26 @@ mod tests { use super::*; use codex_backend_client::TokenUsageProfileDailyBucket; use codex_backend_client::TokenUsageProfileStats; + use codex_core::config::ConfigBuilder; use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[tokio::test] + async fn account_login_storage_uses_auth_home() { + let codex_home = TempDir::new().expect("codex home"); + let auth_home = TempDir::new().expect("auth home"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .auth_home(auth_home.path().to_path_buf()) + .build() + .await + .expect("build config"); + + assert_eq!( + AccountRequestProcessor::auth_storage_home(&config), + auth_home.path() + ); + } #[test] fn account_token_usage_response_maps_profile_stats_and_daily_buckets() { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6e403831622c..d59931ee33c7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -83,6 +83,7 @@ use crate::version::CODEX_CLI_VERSION; use crate::workspace_command::AppServerWorkspaceCommandRunner; use crate::workspace_command::WorkspaceCommandRunner; use codex_ansi_escape::ansi_escape_line; +use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::AddCreditsNudgeCreditType; @@ -132,6 +133,8 @@ use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WriteStatus; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; use codex_config::types::ApprovalsReviewer; @@ -529,6 +532,9 @@ pub(crate) struct App { feedback_audience: FeedbackAudience, environment_manager: Arc, app_server_target: AppServerTarget, + arg0_paths: Arg0DispatchPaths, + strict_config: bool, + cloud_config_bundle: CloudConfigBundleLoader, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -719,9 +725,12 @@ impl App { tui: &mut tui::Tui, mut app_server: AppServerSession, mut config: Config, + arg0_paths: Arg0DispatchPaths, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, + strict_config: bool, + cloud_config_bundle: CloudConfigBundleLoader, initial_prompt: Option, initial_images: Vec, session_selection: SessionSelection, @@ -1026,6 +1035,9 @@ See the Codex keymap documentation for supported actions and examples." feedback_audience, environment_manager, app_server_target, + arg0_paths, + strict_config, + cloud_config_bundle, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 49b87e3d0565..f2065ee67360 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -321,6 +321,9 @@ impl App { .add_error_message(format!("Logout failed: {err}")); } }, + AppEvent::SwitchAuthProfile { selection } => { + self.switch_auth_profile(tui, app_server, selection).await; + } AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 5e7985f108f4..079bce85a7bb 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -5,6 +5,7 @@ //! cache used for multi-agent navigation. use super::*; +use crate::app_event::AuthProfileSelection; impl App { pub(super) async fn open_agent_picker(&mut self, app_server: &mut AppServerSession) { @@ -473,20 +474,49 @@ impl App { // until the new session is configured and any replayed turns have been rendered. self.refresh_in_memory_config_from_disk_best_effort("starting a new thread") .await; - let model = self.chat_widget.current_model().to_string(); let config = self.fresh_session_config(); + self.start_fresh_session_with_config( + tui, + app_server, + config, + session_start_source, + initial_user_message, + /*cleanup_current_thread*/ true, + "To continue this session, run ", + "Failed to attach to fresh app-server thread", + "Failed to start a fresh session through the app server", + ) + .await; + } + + #[allow(clippy::too_many_arguments)] + async fn start_fresh_session_with_config( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + config: Config, + session_start_source: Option, + initial_user_message: Option, + cleanup_current_thread: bool, + resume_hint_prefix: &'static str, + attach_error_prefix: &'static str, + start_error_prefix: &'static str, + ) -> bool { + let model = self.chat_widget.current_model().to_string(); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), self.chat_widget.thread_name(), self.chat_widget.rollout_path().as_deref(), ); - self.shutdown_current_thread(app_server).await; - let tracked_thread_ids: Vec = - self.thread_event_channels.keys().copied().collect(); - for thread_id in tracked_thread_ids { - if let Err(err) = app_server.thread_unsubscribe(thread_id).await { - tracing::warn!("failed to unsubscribe tracked thread {thread_id}: {err}"); + if cleanup_current_thread { + self.shutdown_current_thread(app_server).await; + let tracked_thread_ids: Vec = + self.thread_event_channels.keys().copied().collect(); + for thread_id in tracked_thread_ids { + if let Err(err) = app_server.thread_unsubscribe(thread_id).await { + tracing::warn!("failed to unsubscribe tracked thread {thread_id}: {err}"); + } } } self.config = config.clone(); @@ -495,7 +525,7 @@ impl App { .await { Ok(started) => { - if let Err(err) = self + match self .replace_chat_widget_with_app_server_thread( tui, app_server, @@ -504,29 +534,37 @@ impl App { ) .await { - self.chat_widget.add_error_message(format!( - "Failed to attach to fresh app-server thread: {err}" - )); - } else if let Some(summary) = summary { - let mut lines: Vec> = Vec::new(); - if let Some(usage_line) = summary.usage_line { - lines.push(usage_line.into()); + Ok(()) => { + if let Some(summary) = summary { + let mut lines: Vec> = Vec::new(); + if let Some(usage_line) = summary.usage_line { + lines.push(usage_line.into()); + } + if let Some(command) = summary.resume_hint { + let spans = vec![resume_hint_prefix.into(), command.cyan()]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + tui.frame_requester().schedule_frame(); + true } - if let Some(command) = summary.resume_hint { - let spans = vec!["To continue this session, run ".into(), command.cyan()]; - lines.push(spans.into()); + Err(err) => { + self.chat_widget + .add_error_message(format!("{attach_error_prefix}: {err}")); + tui.frame_requester().schedule_frame(); + false } - self.chat_widget.add_plain_history_lines(lines); } } Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to start a fresh session through the app server: {err}" - )); + self.chat_widget + .add_error_message(format!("{start_error_prefix}: {err}")); self.config.model = Some(model); + tui.frame_requester().schedule_frame(); + false } } - tui.frame_requester().schedule_frame(); } pub(super) async fn replace_chat_widget_with_app_server_thread( @@ -659,6 +697,123 @@ impl App { config.service_tier = self.chat_widget.configured_service_tier(); config } + + pub(super) async fn switch_auth_profile( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + selection: AuthProfileSelection, + ) { + if !matches!(self.app_server_target, crate::AppServerTarget::Embedded) { + self.chat_widget.add_error_message( + "/login profile switching requires an embedded Codex Lab app server.".to_string(), + ); + self.chat_widget.add_info_message( + "Restart Codex Lab with `--auth-profile ` to use a profile with a shared or remote app server." + .to_string(), + /*hint*/ None, + ); + return; + } + + let (auth_home, profile_label) = match selection { + AuthProfileSelection::Default => { + (self.config.codex_home.to_path_buf(), "default".to_string()) + } + AuthProfileSelection::Named { profile_name } => { + let auth_home = + match codex_login::profile_home(&self.config.codex_home, &profile_name) { + Ok(auth_home) => auth_home, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Invalid auth profile {profile_name:?}: {err}" + )); + return; + } + }; + (auth_home, format!("`{profile_name}`")) + } + }; + + if auth_home == self.config.auth_home.as_path() { + self.chat_widget.add_info_message( + format!("Auth profile {profile_label} is already active."), + /*hint*/ None, + ); + return; + } + + let mut config = self.fresh_session_config(); + config.auth_home = match AbsolutePathBuf::from_absolute_path(auth_home) { + Ok(auth_home) => auth_home, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Invalid auth profile home for {profile_label}: {err}" + )); + return; + } + }; + + let replacement_client = match crate::start_embedded_app_server( + self.arg0_paths.clone(), + config.clone(), + self.cli_kv_overrides.clone(), + self.loader_overrides.clone(), + self.strict_config, + self.cloud_config_bundle.clone(), + self.feedback.clone(), + /*log_db*/ None, + self.state_db.clone(), + self.environment_manager.clone(), + ) + .await + { + Ok(client) => AppServerClient::InProcess(client), + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to start app server for auth profile {profile_label}: {err}" + )); + return; + } + }; + + self.shutdown_current_thread(app_server).await; + let tracked_thread_ids: Vec = + self.thread_event_channels.keys().copied().collect(); + for thread_id in tracked_thread_ids { + if let Err(err) = app_server.thread_unsubscribe(thread_id).await { + tracing::warn!("failed to unsubscribe tracked thread {thread_id}: {err}"); + } + } + + if let Err(err) = app_server.replace_client(replacement_client).await { + self.chat_widget.add_error_message(format!( + "Failed to switch app server to auth profile {profile_label}: {err}" + )); + return; + } + + let switched = self + .start_fresh_session_with_config( + tui, + app_server, + config, + Some(ThreadStartSource::Clear), + /*initial_user_message*/ None, + /*cleanup_current_thread*/ false, + "To continue the previous session, run ", + "Failed to attach to auth profile session", + "Failed to start auth profile session", + ) + .await; + if switched { + self.chat_widget.add_info_message( + format!("Using auth profile {profile_label} for this session."), + Some("The previous session remains resumable.".to_string()), + ); + } + } + pub(super) async fn resume_target_session( &mut self, tui: &mut tui::Tui, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index c59102fa6d8e..5e3cd8d0909a 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -46,6 +46,9 @@ pub(super) async fn make_test_app() -> App { feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, + arg0_paths: Arg0DispatchPaths::default(), + strict_config: false, + cloud_config_bundle: CloudConfigBundleLoader::default(), pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 3286b189c404..7ba739ec2daf 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3798,6 +3798,9 @@ async fn make_test_app() -> App { feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, + arg0_paths: Arg0DispatchPaths::default(), + strict_config: false, + cloud_config_bundle: CloudConfigBundleLoader::default(), pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), @@ -3862,6 +3865,9 @@ async fn make_test_app_with_channels() -> ( feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, + arg0_paths: Arg0DispatchPaths::default(), + strict_config: false, + cloud_config_bundle: CloudConfigBundleLoader::default(), pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9f9aaa4ba87d..b92d36e596c1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -75,6 +75,12 @@ pub(crate) struct HistoryLookupResponse { pub(crate) entry: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AuthProfileSelection { + Default, + Named { profile_name: String }, +} + impl RealtimeAudioDeviceKind { pub(crate) fn title(self) -> &'static str { match self { @@ -234,6 +240,11 @@ pub(crate) enum AppEvent { /// Request app-server account logout, then exit after it succeeds. Logout, + /// Start a fresh session using the selected auth profile for credential storage. + SwitchAuthProfile { + selection: AuthProfileSelection, + }, + /// Request to exit the application due to a fatal error. #[allow(dead_code)] FatalExitRequest(String), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 791601c6584b..f9f6bff4dd86 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1124,6 +1124,15 @@ impl AppServerSession { self.client.shutdown().await } + pub(crate) async fn replace_client(&mut self, client: AppServerClient) -> std::io::Result<()> { + let old_client = std::mem::replace(&mut self.client, client); + self.next_request_id = 1; + self.thread_settings_update_supported = true; + self.default_model = None; + self.available_models.clear(); + old_client.shutdown().await + } + pub(crate) fn request_handle(&self) -> AppServerRequestHandle { self.client.request_handle() } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 48caf8f54d21..1d5696f55ef1 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -7,6 +7,7 @@ use super::goal_validation::GoalObjectiveValidationSource; use super::*; +use crate::app_event::AuthProfileSelection; use crate::app_event::ThreadGoalSetMode; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::slash_commands::BuiltinCommandFlags; @@ -34,9 +35,111 @@ const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting..."; const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Ctrl+C to return to the main thread first."; const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; +const LOGIN_USAGE: &str = "Usage: /login [default|]"; const RAW_USAGE: &str = "Usage: /raw [on|off]"; impl ChatWidget { + fn open_login_profile_picker(&mut self) { + let profiles = match codex_login::list_auth_profiles(&self.config.codex_home) { + Ok(profiles) => profiles, + Err(err) => { + self.add_error_message(format!("Failed to list auth profiles: {err}")); + return; + } + }; + + let current_auth_home = self.config.auth_home.to_path_buf(); + let default_auth_home = self.config.codex_home.to_path_buf(); + let codex_home = self.config.codex_home.to_path_buf(); + let mut items = Vec::with_capacity(profiles.len() + 2); + items.push(SelectionItem { + name: "default".to_string(), + description: Some("Use the default Codex Lab login".to_string()), + is_current: current_auth_home == default_auth_home, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::SwitchAuthProfile { + selection: AuthProfileSelection::Default, + }); + })], + dismiss_on_select: true, + ..Default::default() + }); + + for profile in profiles { + let name = profile.name.clone(); + let description = login_profile_description(&profile); + let is_current = current_auth_home == profile.home; + items.push(SelectionItem { + name: name.clone(), + description: Some(description), + is_current, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SwitchAuthProfile { + selection: AuthProfileSelection::Named { + profile_name: name.clone(), + }, + }); + })], + dismiss_on_select: true, + ..Default::default() + }); + } + + items.push(SelectionItem { + name: "Add login...".to_string(), + description: Some("Run `codex-lab login --profile ` from your shell".to_string()), + is_disabled: true, + disabled_reason: Some(format!( + "Use `codex-lab login --profile `; profiles are stored under {}", + codex_home.display() + )), + ..Default::default() + }); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Choose Login".to_string()), + subtitle: Some("Start a fresh session with the selected account.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Search logins".to_string()), + ..Default::default() + }); + self.request_redraw(); + } + + fn handle_login_command_args(&mut self, trimmed: &str) { + if trimmed.eq_ignore_ascii_case("default") { + self.app_event_tx.send(AppEvent::SwitchAuthProfile { + selection: AuthProfileSelection::Default, + }); + return; + } + + let profiles = match codex_login::list_auth_profiles(&self.config.codex_home) { + Ok(profiles) => profiles, + Err(err) => { + self.add_error_message(format!("Failed to list auth profiles: {err}")); + return; + } + }; + if profiles.iter().any(|profile| profile.name == trimmed) { + self.app_event_tx.send(AppEvent::SwitchAuthProfile { + selection: AuthProfileSelection::Named { + profile_name: trimmed.to_string(), + }, + }); + } else { + self.add_error_message(format!("Unknown auth profile `{trimmed}`.")); + self.add_info_message( + LOGIN_USAGE.to_string(), + Some(format!( + "Add it first with `codex-lab login --profile {trimmed}`." + )), + ); + } + } + /// Dispatch a bare slash command and record its staged local-history entry. /// /// The composer stages history before returning `InputResult::Command`; this wrapper commits @@ -463,6 +566,9 @@ impl ChatWidget { SlashCommand::Plugins => { self.add_plugins_output(); } + SlashCommand::Login => { + self.open_login_profile_picker(); + } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( @@ -631,6 +737,9 @@ impl ChatWidget { "verbose" => self.add_mcp_output(McpServerStatusDetail::Full), _ => self.add_error_message("Usage: /mcp [verbose]".to_string()), }, + SlashCommand::Login if !trimmed.is_empty() => { + self.handle_login_command_args(trimmed); + } SlashCommand::Keymap => match trimmed.to_ascii_lowercase().as_str() { "" => self.open_keymap_picker(), "debug" => { @@ -1016,6 +1125,7 @@ impl ChatWidget { | SlashCommand::Quit | SlashCommand::Exit | SlashCommand::Logout + | SlashCommand::Login | SlashCommand::Mention | SlashCommand::Skills | SlashCommand::Hooks @@ -1076,3 +1186,18 @@ impl ChatWidget { false } } + +fn login_profile_description(profile: &codex_login::AuthProfileEntry) -> String { + let mut details = Vec::new(); + if let Some(email) = profile.metadata.email.as_deref() { + details.push(email.to_string()); + } + if let Some(last_login_at) = profile.metadata.last_login_at.as_ref() { + details.push(format!("last login {last_login_at}")); + } + if details.is_empty() { + "Use this saved auth profile".to_string() + } else { + details.join(" - ") + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 9c2802fb6041..f45197155379 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,4 +1,5 @@ use super::*; +use crate::app_event::AuthProfileSelection; use crate::bottom_pane::slash_commands::ServiceTierCommand; use pretty_assertions::assert_eq; use serial_test::serial; @@ -116,6 +117,44 @@ async fn service_tier_commands_lowercase_catalog_names() { ); } +#[tokio::test] +async fn login_slash_command_opens_profile_picker() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + codex_login::record_auth_profile_login( + &chat.config.codex_home, + "work", + Some("account-1".to_string()), + Some("me@example.com".to_string()), + ) + .expect("record profile login"); + + chat.dispatch_command(SlashCommand::Login); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!(popup.contains("Choose Login")); + assert!(popup.contains("default")); + assert!(popup.contains("work")); + assert!(popup.contains("me@example.com")); + assert!(popup.contains("Add login...")); + assert!(popup.contains("codex-lab login --profile ")); +} + +#[tokio::test] +async fn login_slash_command_with_profile_switches_session_profile() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + codex_login::record_auth_profile_login(&chat.config.codex_home, "work", None, None) + .expect("record profile login"); + + chat.dispatch_command_with_args(SlashCommand::Login, "work".to_string(), Vec::new()); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SwitchAuthProfile { + selection: AuthProfileSelection::Named { profile_name }, + }) if profile_name == "work" + ); +} + #[tokio::test] async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e37db2096a07..d9d8cd5e083f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -297,7 +297,7 @@ const AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50); #[allow(clippy::too_many_arguments)] -async fn start_embedded_app_server( +pub(crate) async fn start_embedded_app_server( arg0_paths: Arg0DispatchPaths, config: Config, cli_kv_overrides: Vec<(String, toml::Value)>, @@ -1793,7 +1793,7 @@ async fn run_ratatui_app( Some(app_server) => app_server, None => match start_app_server( &app_server_target, - arg0_paths, + arg0_paths.clone(), config.clone(), cli_kv_overrides.clone(), loader_overrides.clone(), @@ -1864,9 +1864,12 @@ async fn run_ratatui_app( &mut tui, app_server, config, + arg0_paths, cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), prompt, images, session_selection, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 805f266e0afe..e91d8a8ddade 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -55,6 +55,7 @@ pub enum SlashCommand { Mcp, Apps, Plugins, + Login, Logout, Quit, Exit, @@ -135,6 +136,7 @@ impl SlashCommand { SlashCommand::Mcp => "list configured MCP tools; use /mcp verbose for details", SlashCommand::Apps => "manage apps", SlashCommand::Plugins => "browse plugins", + SlashCommand::Login => "choose the account for this session", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", SlashCommand::TestApproval => "test approval request", @@ -159,6 +161,7 @@ impl SlashCommand { | SlashCommand::Keymap | SlashCommand::Mcp | SlashCommand::Raw + | SlashCommand::Login | SlashCommand::Pets | SlashCommand::Side | SlashCommand::Btw @@ -202,6 +205,7 @@ impl SlashCommand { | SlashCommand::Plan | SlashCommand::Clear | SlashCommand::Logout + | SlashCommand::Login | SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate => false, SlashCommand::Diff