From 5fd04ed374bef491597dbab180fd8d74b623c946 Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Wed, 17 Jun 2026 14:17:05 -0400 Subject: [PATCH] feat: add TUI auth profile login flow --- codex-rs/tui/src/app.rs | 11 +- codex-rs/tui/src/app/app_server_events.rs | 70 +++++++++++- codex-rs/tui/src/app/event_dispatch.rs | 4 + codex-rs/tui/src/app/session_lifecycle.rs | 105 ++++++++++++++++-- codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 8 +- codex-rs/tui/src/app_event.rs | 10 +- codex-rs/tui/src/app_server_session.rs | 2 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 47 ++++++-- .../src/chatwidget/tests/slash_commands.rs | 40 ++++++- 10 files changed, 272 insertions(+), 26 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d59931ee33c7..ad0775d53d4b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -569,6 +569,14 @@ pub(crate) struct App { // Serialize hook enablement writes per hook so stale completions cannot // persist an older toggle after a newer one. pending_hook_enabled_writes: HashMap>, + pending_auth_profile_login: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PendingAuthProfileLogin { + pub(crate) login_id: String, + pub(crate) profile_name: String, + pub(crate) profile_label: String, } #[derive(Debug, Clone, PartialEq)] @@ -1055,6 +1063,7 @@ See the Codex keymap documentation for supported actions and examples." pending_startup_thread_start, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), + pending_auth_profile_login: None, }; if let Some(entry) = startup_hooks_browser { app.chat_widget.open_hooks_browser(entry); @@ -1189,7 +1198,7 @@ See the Codex keymap documentation for supported actions and examples." } app_server_event = app_server.next_event(), if listen_for_app_server_events => { match app_server_event { - Some(event) => app.handle_app_server_event(&app_server, event).await, + Some(event) => app.handle_app_server_event(&mut app_server, event).await, None => { listen_for_app_server_events = false; tracing::warn!("app-server event stream closed"); diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index fd044ca6bcf7..22d00be984d1 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -10,6 +10,8 @@ use crate::app_event::ConnectorsSnapshot; use crate::app_server_session::AppServerSession; use crate::app_server_session::status_account_display_from_auth_mode; use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::AccountLoginCompletedNotification; +use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::BackgroundAutoReviewStatus; use codex_app_server_protocol::ServerNotification; @@ -32,7 +34,7 @@ impl App { pub(super) async fn handle_app_server_event( &mut self, - app_server_client: &AppServerSession, + app_server_client: &mut AppServerSession, event: AppServerEvent, ) { match event { @@ -62,7 +64,7 @@ impl App { async fn handle_server_notification_event( &mut self, - app_server_client: &AppServerSession, + app_server_client: &mut AppServerSession, notification: ServerNotification, ) { match ¬ification { @@ -82,6 +84,10 @@ impl App { .on_rolling_rate_limit_snapshot(notification.rate_limits.clone()); return; } + ServerNotification::AccountLoginCompleted(notification) => { + self.handle_auth_profile_login_completed(notification); + return; + } ServerNotification::AccountUpdated(notification) => { self.chat_widget.update_account_state( status_account_display_from_auth_mode( @@ -93,6 +99,7 @@ impl App { .auth_mode .is_some_and(AuthMode::has_chatgpt_account), ); + self.maybe_record_completed_auth_profile_login(notification); return; } ServerNotification::ExternalAgentConfigImportCompleted(_) => { @@ -210,4 +217,63 @@ impl App { tracing::warn!("failed to enqueue app-server request: {err}"); } } + + fn handle_auth_profile_login_completed( + &mut self, + notification: &AccountLoginCompletedNotification, + ) { + let Some(login_id) = notification.login_id.as_deref() else { + return; + }; + let Some(pending) = self.pending_auth_profile_login.as_ref() else { + return; + }; + if pending.login_id != login_id { + return; + } + if !notification.success { + let pending = self.pending_auth_profile_login.take().unwrap(); + self.chat_widget.add_error_message(format!( + "Login for auth profile {} failed.", + pending.profile_label + )); + if let Some(error) = notification.error.as_ref() { + self.chat_widget + .add_info_message(error.clone(), /*hint*/ None); + } + } + } + + fn maybe_record_completed_auth_profile_login( + &mut self, + notification: &AccountUpdatedNotification, + ) { + if !notification + .auth_mode + .is_some_and(AuthMode::has_chatgpt_account) + { + return; + } + let Some(pending) = self.pending_auth_profile_login.take() else { + return; + }; + + if let Err(err) = codex_login::record_auth_profile_login( + &self.config.codex_home, + &pending.profile_name, + /*account_id*/ None, + /*email*/ None, + ) { + self.chat_widget.add_error_message(format!( + "Logged in, but failed to update auth profile {}: {err}", + pending.profile_label + )); + return; + } + + self.chat_widget.add_info_message( + format!("Logged in to auth profile {}.", pending.profile_label), + /*hint*/ None, + ); + } } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index f2065ee67360..aa49d673b0cf 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -61,6 +61,10 @@ impl App { ) .await; } + AppEvent::SetComposerText { text } => { + self.chat_widget + .set_composer_text(text, Vec::new(), Vec::new()); + } AppEvent::OpenResumePicker => { let picker_app_server = match crate::start_app_server_for_picker( &self.config, diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 079bce85a7bb..8d7166f2c23d 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -6,6 +6,9 @@ use super::*; use crate::app_event::AuthProfileSelection; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::LoginAccountParams; +use codex_app_server_protocol::LoginAccountResponse; impl App { pub(super) async fn open_agent_picker(&mut self, app_server: &mut AppServerSession) { @@ -704,6 +707,8 @@ impl App { app_server: &mut AppServerSession, selection: AuthProfileSelection, ) { + self.pending_auth_profile_login = None; + 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(), @@ -716,13 +721,18 @@ impl App { 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, profile_name, profile_label) = match selection { + AuthProfileSelection::Default => ( + self.config.codex_home.to_path_buf(), + None, + "default".to_string(), + ), + AuthProfileSelection::Named { + ref profile_name, .. + } => { let auth_home = - match codex_login::profile_home(&self.config.codex_home, &profile_name) { + match codex_login::profile_home(&self.config.codex_home, profile_name.as_str()) + { Ok(auth_home) => auth_home, Err(err) => { self.chat_widget.add_error_message(format!( @@ -731,15 +741,31 @@ impl App { return; } }; - (auth_home, format!("`{profile_name}`")) + ( + auth_home, + Some(profile_name.clone()), + format!("`{profile_name}`"), + ) } }; + let login_after_switch = matches!( + &selection, + AuthProfileSelection::Named { + login_after_switch: true, + .. + } + ); + 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, ); + if login_after_switch { + self.start_auth_profile_login(app_server, profile_name.as_deref(), &profile_label) + .await; + } return; } @@ -811,6 +837,71 @@ impl App { format!("Using auth profile {profile_label} for this session."), Some("The previous session remains resumable.".to_string()), ); + if login_after_switch { + self.start_auth_profile_login(app_server, profile_name.as_deref(), &profile_label) + .await; + } + } + } + + async fn start_auth_profile_login( + &mut self, + app_server: &mut AppServerSession, + profile_name: Option<&str>, + profile_label: &str, + ) { + let request_handle = app_server.request_handle(); + let response = request_handle + .request_typed::(ClientRequest::LoginAccount { + request_id: app_server.next_request_id(), + params: LoginAccountParams::Chatgpt { + codex_streamlined_login: false, + }, + }) + .await; + + match response { + Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => { + if let Some(profile_name) = profile_name { + self.pending_auth_profile_login = Some(PendingAuthProfileLogin { + login_id, + profile_name: profile_name.to_string(), + profile_label: profile_label.to_string(), + }); + } + self.open_url_in_browser(auth_url.clone()); + self.chat_widget.add_info_message( + format!("Started ChatGPT login for auth profile {profile_label}."), + Some(format!("If your browser did not open, visit {auth_url}")), + ); + } + Ok(LoginAccountResponse::ApiKey {}) => { + self.chat_widget.add_info_message( + format!("API key login configured for auth profile {profile_label}."), + /*hint*/ None, + ); + } + Ok(LoginAccountResponse::ChatgptDeviceCode { + verification_url, + user_code, + .. + }) => { + self.chat_widget.add_info_message( + format!("Started device-code login for auth profile {profile_label}."), + Some(format!("Visit {verification_url} and enter {user_code}.")), + ); + } + Ok(LoginAccountResponse::ChatgptAuthTokens {}) => { + self.chat_widget.add_info_message( + format!("ChatGPT tokens configured for auth profile {profile_label}."), + /*hint*/ None, + ); + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to start login for auth profile {profile_label}: {err}" + )); + } } } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 5e3cd8d0909a..a1484933cad5 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -66,6 +66,7 @@ pub(super) async fn make_test_app() -> App { pending_startup_thread_start: false, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), + pending_auth_profile_login: None, } } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 7ba739ec2daf..8a7a057f7e56 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3359,7 +3359,7 @@ async fn side_thread_snapshot_skips_session_header_preamble() { async fn side_thread_ignores_global_mcp_startup_notifications() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; while app_event_rx.try_recv().is_ok() {} - let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) .await .expect("embedded app server"); let parent_thread_id = ThreadId::new(); @@ -3371,7 +3371,7 @@ async fn side_thread_ignores_global_mcp_startup_notifications() { app.sync_side_thread_ui(); app.handle_app_server_event( - &app_server, + &mut app_server, codex_app_server_client::AppServerEvent::ServerNotification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { name: "sentry".to_string(), @@ -3818,6 +3818,7 @@ async fn make_test_app() -> App { pending_startup_thread_start: false, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), + pending_auth_profile_login: None, } } @@ -3885,6 +3886,7 @@ async fn make_test_app_with_channels() -> ( pending_startup_thread_start: false, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), + pending_auth_profile_login: None, }, rx, op_rx, @@ -5332,7 +5334,7 @@ async fn override_turn_context_sends_thread_settings_update() { ); app.handle_app_server_event( - &app_server, + &mut app_server, codex_app_server_client::AppServerEvent::ServerNotification( ServerNotification::ThreadSettingsUpdated(notification), ), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b92d36e596c1..6ed44b93f083 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -78,7 +78,10 @@ pub(crate) struct HistoryLookupResponse { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum AuthProfileSelection { Default, - Named { profile_name: String }, + Named { + profile_name: String, + login_after_switch: bool, + }, } impl RealtimeAudioDeviceKind { @@ -217,6 +220,11 @@ pub(crate) enum AppEvent { text: String, }, + /// Replace the composer contents without submitting. + SetComposerText { + text: String, + }, + /// Open the resume picker inside the running TUI session. OpenResumePicker, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f9f6bff4dd86..a2cf4e9cf5e8 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1137,7 +1137,7 @@ impl AppServerSession { self.client.request_handle() } - fn next_request_id(&mut self) -> RequestId { + pub(crate) fn next_request_id(&mut self) -> RequestId { let request_id = self.next_request_id; self.next_request_id += 1; RequestId::Integer(request_id) diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 1d5696f55ef1..92f6f45fe7b9 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -35,7 +35,7 @@ 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 LOGIN_USAGE: &str = "Usage: /login [default||add ]"; const RAW_USAGE: &str = "Usage: /raw [on|off]"; impl ChatWidget { @@ -51,6 +51,7 @@ impl ChatWidget { 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 add_command = "/login add ".to_string(); let mut items = Vec::with_capacity(profiles.len() + 2); items.push(SelectionItem { name: "default".to_string(), @@ -77,6 +78,7 @@ impl ChatWidget { tx.send(AppEvent::SwitchAuthProfile { selection: AuthProfileSelection::Named { profile_name: name.clone(), + login_after_switch: false, }, }); })], @@ -87,10 +89,15 @@ impl ChatWidget { 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 {}", + description: Some("Create a named login profile in this session".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SetComposerText { + text: add_command.clone(), + }); + })], + dismiss_on_select: true, + selected_description: Some(format!( + "Type a profile name. Profiles are stored under {}", codex_home.display() )), ..Default::default() @@ -116,6 +123,31 @@ impl ChatWidget { return; } + if let Some(profile_name) = trimmed.strip_prefix("add ").map(str::trim) { + if profile_name.eq_ignore_ascii_case("default") { + self.add_error_message( + "`default` is reserved for the built-in Codex Lab login.".to_string(), + ); + self.add_info_message(LOGIN_USAGE.to_string(), /*hint*/ None); + return; + } + match codex_login::validate_profile_name(profile_name) { + Ok(profile_name) => { + self.app_event_tx.send(AppEvent::SwitchAuthProfile { + selection: AuthProfileSelection::Named { + profile_name: profile_name.to_string(), + login_after_switch: true, + }, + }); + } + Err(err) => { + self.add_error_message(format!("Invalid auth profile `{profile_name}`: {err}")); + self.add_info_message(LOGIN_USAGE.to_string(), /*hint*/ None); + } + } + return; + } + let profiles = match codex_login::list_auth_profiles(&self.config.codex_home) { Ok(profiles) => profiles, Err(err) => { @@ -127,15 +159,14 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::SwitchAuthProfile { selection: AuthProfileSelection::Named { profile_name: trimmed.to_string(), + login_after_switch: false, }, }); } 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}`." - )), + Some(format!("Add it with `/login add {trimmed}`.")), ); } } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index f45197155379..97961c0fc97b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -136,7 +136,7 @@ async fn login_slash_command_opens_profile_picker() { assert!(popup.contains("work")); assert!(popup.contains("me@example.com")); assert!(popup.contains("Add login...")); - assert!(popup.contains("codex-lab login --profile ")); + assert!(popup.contains("Create a named login profile")); } #[tokio::test] @@ -150,8 +150,42 @@ async fn login_slash_command_with_profile_switches_session_profile() { assert_matches!( rx.try_recv(), Ok(AppEvent::SwitchAuthProfile { - selection: AuthProfileSelection::Named { profile_name }, - }) if profile_name == "work" + selection: AuthProfileSelection::Named { + profile_name, + login_after_switch, + }, + }) if profile_name == "work" && !login_after_switch + ); +} + +#[tokio::test] +async fn login_slash_command_add_profile_starts_profile_login() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + + chat.dispatch_command_with_args(SlashCommand::Login, "add work".to_string(), Vec::new()); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SwitchAuthProfile { + selection: AuthProfileSelection::Named { + profile_name, + login_after_switch, + }, + }) if profile_name == "work" && login_after_switch + ); +} + +#[tokio::test] +async fn login_slash_command_add_rejects_invalid_profile_name() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + + chat.dispatch_command_with_args(SlashCommand::Login, "add ../work".to_string(), Vec::new()); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events + .iter() + .all(|event| { !matches!(event, AppEvent::SwitchAuthProfile { .. }) }) ); }