From cd05014c767ad775a88d68b714d76bc83d31ccab Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 14 Mar 2026 11:03:56 -0600 Subject: [PATCH] Part 3 --- codex-rs/tui/src/lib.rs | 89 ++-- codex-rs/tui/src/onboarding/account_login.rs | 126 ++++++ codex-rs/tui/src/onboarding/auth.rs | 373 ++++++++--------- .../onboarding/auth/headless_chatgpt_login.rs | 387 ------------------ codex-rs/tui/src/onboarding/mod.rs | 1 + .../tui/src/onboarding/onboarding_screen.rs | 244 ++++++++--- ..._tests__device_code_login_unavailable.snap | 45 ++ 7 files changed, 599 insertions(+), 666 deletions(-) create mode 100644 codex-rs/tui/src/onboarding/account_login.rs delete mode 100644 codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs create mode 100644 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__tests__device_code_login_unavailable.snap diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index bdc529ebf0..8f906c15dd 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -13,7 +13,6 @@ use codex_app_server_client::InProcessClientStartArgs; use codex_app_server_protocol::ConfigWarningNotification; use codex_cloud_requirements::cloud_requirements_loader; use codex_core::AuthManager; -use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; use codex_core::ThreadSortKey; @@ -227,6 +226,7 @@ mod wrapping; #[cfg(test)] pub mod test_backend; +use crate::onboarding::account_login::read_login_status_via_app_server; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; @@ -663,12 +663,44 @@ async fn run_ratatui_app( // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&initial_config); - let auth_manager = AuthManager::shared( - initial_config.codex_home.clone(), - false, - initial_config.cli_auth_credentials_store_mode, - ); - let login_status = get_login_status(&initial_config); + let mut onboarding_app_server = if initial_config.model_provider.requires_openai_auth { + Some( + match start_embedded_app_server( + arg0_paths.clone(), + initial_config.clone(), + cli_kv_overrides.clone(), + loader_overrides.clone(), + cloud_requirements.clone(), + feedback.clone(), + ) + .await + { + Ok(app_server) => app_server, + Err(err) => { + restore(); + session_log::log_session_end(); + return Err(err); + } + }, + ) + } else { + None + }; + let login_status = if let Some(app_server) = onboarding_app_server.as_ref() { + match read_login_status_via_app_server(app_server).await { + Ok(status) => status, + Err(err) => { + if let Some(app_server) = onboarding_app_server.take() { + let _ = app_server.shutdown().await; + } + restore(); + session_log::log_session_end(); + return Err(color_eyre::eyre::eyre!("{err}")); + } + } + } else { + LoginStatus::NotAuthenticated + }; let should_show_trust_screen_flag = should_show_trust_screen(&initial_config); let should_show_onboarding = should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); @@ -681,12 +713,21 @@ async fn run_ratatui_app( show_login_screen, show_trust_screen: should_show_trust_screen_flag, login_status, - auth_manager: auth_manager.clone(), config: initial_config.clone(), }, &mut tui, + onboarding_app_server + .as_mut() + .and_then(|client| show_login_screen.then_some(client)), ) - .await?; + .await; + if let Some(app_server) = onboarding_app_server.take() { + app_server + .shutdown() + .await + .wrap_err("failed to shut down onboarding app server")?; + } + let onboarding_result = onboarding_result?; if onboarding_result.should_exit { restore(); session_log::log_session_end(); @@ -705,7 +746,11 @@ async fn run_ratatui_app( // status detection edge cases. if show_login_screen { cloud_requirements = cloud_requirements_loader( - auth_manager.clone(), + AuthManager::shared( + initial_config.codex_home.clone(), + false, + initial_config.cli_auth_credentials_store_mode, + ), initial_config.chatgpt_base_url.clone(), initial_config.codex_home.clone(), ); @@ -724,6 +769,12 @@ async fn run_ratatui_app( initial_config } } else { + if let Some(app_server) = onboarding_app_server.take() { + app_server + .shutdown() + .await + .wrap_err("failed to shut down onboarding app server")?; + } initial_config }; @@ -1198,24 +1249,6 @@ pub enum LoginStatus { NotAuthenticated, } -fn get_login_status(config: &Config) -> LoginStatus { - if config.model_provider.requires_openai_auth { - // Reading the OpenAI API key is an async operation because it may need - // to refresh the token. Block on it. - let codex_home = config.codex_home.clone(); - match CodexAuth::from_auth_storage(&codex_home, config.cli_auth_credentials_store_mode) { - Ok(Some(auth)) => LoginStatus::AuthMode(auth.auth_mode()), - Ok(None) => LoginStatus::NotAuthenticated, - Err(err) => { - error!("Failed to read auth.json: {err}"); - LoginStatus::NotAuthenticated - } - } - } else { - LoginStatus::NotAuthenticated - } -} - async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, diff --git a/codex-rs/tui/src/onboarding/account_login.rs b/codex-rs/tui/src/onboarding/account_login.rs new file mode 100644 index 0000000000..c32dae51b9 --- /dev/null +++ b/codex-rs/tui/src/onboarding/account_login.rs @@ -0,0 +1,126 @@ +use codex_app_server_client::InProcessAppServerClient; +use codex_app_server_protocol::Account; +use codex_app_server_protocol::CancelLoginAccountParams; +use codex_app_server_protocol::CancelLoginAccountResponse; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountResponse; +use codex_app_server_protocol::LoginAccountParams; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_core::auth::AuthMode; +use serde::de::DeserializeOwned; + +use crate::LoginStatus; + +pub(crate) enum AuthCommand { + StartApiKey { api_key: String }, + StartChatgpt, + CancelChatgpt { login_id: String }, +} + +#[derive(Default)] +pub(crate) struct OnboardingAccountApi { + next_request_id: i64, +} + +impl OnboardingAccountApi { + pub(crate) async fn read_account( + &mut self, + app_server: &InProcessAppServerClient, + ) -> Result { + send_request_with_response( + app_server, + ClientRequest::GetAccount { + request_id: self.next_request_id(), + params: GetAccountParams { + refresh_token: false, + }, + }, + "account/read", + ) + .await + } + + pub(crate) async fn start_api_key_login( + &mut self, + app_server: &InProcessAppServerClient, + api_key: String, + ) -> Result { + send_request_with_response( + app_server, + ClientRequest::LoginAccount { + request_id: self.next_request_id(), + params: LoginAccountParams::ApiKey { api_key }, + }, + "account/login/start", + ) + .await + } + + pub(crate) async fn start_chatgpt_login( + &mut self, + app_server: &InProcessAppServerClient, + ) -> Result { + send_request_with_response( + app_server, + ClientRequest::LoginAccount { + request_id: self.next_request_id(), + params: LoginAccountParams::Chatgpt, + }, + "account/login/start", + ) + .await + } + + pub(crate) async fn cancel_chatgpt_login( + &mut self, + app_server: &InProcessAppServerClient, + login_id: String, + ) -> Result { + send_request_with_response( + app_server, + ClientRequest::CancelLoginAccount { + request_id: self.next_request_id(), + params: CancelLoginAccountParams { login_id }, + }, + "account/login/cancel", + ) + .await + } + + fn next_request_id(&mut self) -> RequestId { + self.next_request_id += 1; + RequestId::Integer(self.next_request_id) + } +} + +pub(crate) fn login_status_from_account(account: Option<&Account>) -> LoginStatus { + match account { + Some(Account::ApiKey {}) => LoginStatus::AuthMode(AuthMode::ApiKey), + Some(Account::Chatgpt { .. }) => LoginStatus::AuthMode(AuthMode::Chatgpt), + None => LoginStatus::NotAuthenticated, + } +} + +pub(crate) async fn read_login_status_via_app_server( + app_server: &InProcessAppServerClient, +) -> Result { + let mut api = OnboardingAccountApi::default(); + let response = api.read_account(app_server).await?; + Ok(login_status_from_account(response.account.as_ref())) +} + +async fn send_request_with_response( + app_server: &InProcessAppServerClient, + request: ClientRequest, + method: &str, +) -> Result +where + T: DeserializeOwned, +{ + app_server + .request_typed(request) + .await + .map_err(|err| format!("{method} failed: {err}")) +} diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index fdc2b6bdad..6c0d7e4ec9 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1,14 +1,9 @@ #![allow(clippy::unwrap_used)] -use codex_core::AuthManager; -use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::auth::CLIENT_ID; -use codex_core::auth::login_with_api_key; +use codex_app_server_protocol::Account; +use codex_app_server_protocol::AccountLoginCompletedNotification; +use codex_core::auth::AuthMode; use codex_core::auth::read_openai_api_key_from_env; -use codex_login::DeviceCode; -use codex_login::ServerOptions; -use codex_login::ShutdownHandle; -use codex_login::run_login_server; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -29,10 +24,11 @@ use ratatui::widgets::Borders; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; - -use codex_core::auth::AuthMode; -use codex_protocol::config_types::ForcedLoginMethod; +use std::sync::Arc; use std::sync::RwLock; +use tokio::sync::mpsc::UnboundedSender; + +use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::onboarding::onboarding_screen::KeyboardHandler; @@ -40,10 +36,14 @@ use crate::onboarding::onboarding_screen::StepStateProvider; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; +use super::account_login::AuthCommand; +use super::account_login::login_status_from_account; +use super::onboarding_screen::StepState; + /// Marks buffer cells that have cyan+underlined style as an OSC 8 hyperlink. /// /// Terminal emulators recognise the OSC 8 escape sequence and treat the entire -/// marked region as a single clickable link, regardless of row wrapping. This +/// marked region as a single clickable link, regardless of row wrapping. This /// is necessary because ratatui's cell-based rendering emits `MoveTo` at every /// row boundary, which breaks normal terminal URL detection for long URLs that /// wrap across multiple rows. @@ -62,7 +62,6 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { for y in area.top()..area.bottom() { for x in area.left()..area.right() { let cell = &mut buf[(x, y)]; - // Only mark cells that carry the URL's distinctive style. if cell.fg != Color::Cyan || !cell.modifier.contains(Modifier::UNDERLINED) { continue; } @@ -74,19 +73,11 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { } } } -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::Notify; - -use super::onboarding_screen::StepState; - -mod headless_chatgpt_login; #[derive(Clone)] pub(crate) enum SignInState { PickMode, ChatGptContinueInBrowser(ContinueInBrowserState), - ChatGptDeviceCode(ContinueWithDeviceCodeState), ChatGptSuccessMessage, ChatGptSuccess, ApiKeyEntry(ApiKeyInputState), @@ -101,6 +92,7 @@ pub(crate) enum SignInOption { } const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled."; +const DEVICE_CODE_UNAVAILABLE_MESSAGE: &str = "Device code sign-in is not yet available through the app-server onboarding flow. Use browser sign-in instead."; #[derive(Clone, Default)] pub(crate) struct ApiKeyInputState { @@ -109,24 +101,9 @@ pub(crate) struct ApiKeyInputState { } #[derive(Clone)] -/// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up. pub(crate) struct ContinueInBrowserState { auth_url: String, - shutdown_flag: Option, -} - -#[derive(Clone)] -pub(crate) struct ContinueWithDeviceCodeState { - device_code: Option, - cancel: Option>, -} - -impl Drop for ContinueInBrowserState { - fn drop(&mut self) { - if let Some(handle) = &self.shutdown_flag { - handle.shutdown(); - } - } + login_id: Option, } impl KeyboardHandler for AuthModeWidget { @@ -166,21 +143,15 @@ impl KeyboardHandler for AuthModeWidget { KeyCode::Esc => { tracing::info!("Esc pressed"); let mut sign_in_state = self.sign_in_state.write().unwrap(); - match &*sign_in_state { - SignInState::ChatGptContinueInBrowser(_) => { - *sign_in_state = SignInState::PickMode; - drop(sign_in_state); - self.request_frame.schedule_frame(); + if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state { + if let Some(login_id) = state.login_id.clone() { + let _ = self + .auth_command_tx + .send(AuthCommand::CancelChatgpt { login_id }); } - SignInState::ChatGptDeviceCode(state) => { - if let Some(cancel) = &state.cancel { - cancel.notify_one(); - } - *sign_in_state = SignInState::PickMode; - drop(sign_in_state); - self.request_frame.schedule_frame(); - } - _ => {} + *sign_in_state = SignInState::PickMode; + drop(sign_in_state); + self.request_frame.schedule_frame(); } } _ => {} @@ -194,15 +165,12 @@ impl KeyboardHandler for AuthModeWidget { #[derive(Clone)] pub(crate) struct AuthModeWidget { + pub auth_command_tx: UnboundedSender, pub request_frame: FrameRequester, pub highlighted_mode: SignInOption, pub error: Option, pub sign_in_state: Arc>, - pub codex_home: PathBuf, - pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub login_status: LoginStatus, - pub auth_manager: Arc, - pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub animations_enabled: bool, } @@ -270,7 +238,7 @@ impl AuthModeWidget { } SignInOption::DeviceCode => { if self.is_chatgpt_login_allowed() { - self.start_device_code_login(); + self.show_device_code_login_todo(); } } SignInOption::ApiKey => { @@ -290,6 +258,14 @@ impl AuthModeWidget { self.request_frame.schedule_frame(); } + fn show_device_code_login_todo(&mut self) { + // TODO: Restore device-code onboarding once app-server exposes a typed + // login flow for it. + self.error = Some(DEVICE_CODE_UNAVAILABLE_MESSAGE.to_string()); + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + self.request_frame.schedule_frame(); + } + fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) { let mut lines: Vec = vec![ Line::from(vec![ @@ -313,11 +289,11 @@ impl AuthModeWidget { let line1 = if is_selected { Line::from(vec![ - format!("{caret} {index}. ", index = idx + 1).cyan().dim(), + format!("{caret} {}. ", idx + 1).cyan().dim(), text.to_string().cyan(), ]) } else { - format!(" {index}. {text}", index = idx + 1).into() + format!(" {}. {text}", idx + 1).into() }; let line2 = if is_selected { @@ -377,11 +353,7 @@ impl AuthModeWidget { ); lines.push("".into()); } - lines.push( - // AE: Following styles.md, this should probably be Cyan because it's a user input tip. - // But leaving this for a future cleanup. - " Press Enter to continue".dim().into(), - ); + lines.push(" Press Enter to continue".dim().into()); if let Some(err) = &self.error { lines.push("".into()); lines.push(err.as_str().red().into()); @@ -395,7 +367,6 @@ impl AuthModeWidget { fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) { let mut spans = vec![" ".into()]; if self.animations_enabled { - // Schedule a follow-up frame to keep the shimmer animation going. self.request_frame .schedule_frame_in(std::time::Duration::from_millis(100)); spans.extend(shimmer_spans("Finish signing in via your browser")); @@ -408,19 +379,16 @@ impl AuthModeWidget { let auth_url = if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state && !state.auth_url.is_empty() { - lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into()); + lines.push( + " If the link doesn't open automatically, open the following link to authenticate:" + .into(), + ); lines.push("".into()); lines.push(Line::from(vec![ " ".into(), state.auth_url.as_str().cyan().underlined(), ])); lines.push("".into()); - lines.push(Line::from(vec![ - " On a remote or headless machine? Press Esc and choose ".into(), - "Sign in with Device Code".cyan(), - ".".into(), - ])); - lines.push("".into()); Some(state.auth_url.clone()) } else { None @@ -431,8 +399,6 @@ impl AuthModeWidget { .wrap(Wrap { trim: false }) .render(area, buf); - // Wrap cyan+underlined URL cells with OSC 8 so the terminal treats - // the entire region as a single clickable hyperlink. if let Some(url) = &auth_url { mark_url_hyperlink(buf, area, url); } @@ -600,7 +566,6 @@ impl AuthModeWidget { } _ => {} } - // handled; let guard drop before potential save } else { return false; } @@ -673,34 +638,14 @@ impl AuthModeWidget { self.disallow_api_login(); return; } - match login_with_api_key( - &self.codex_home, - &api_key, - self.cli_auth_credentials_store_mode, - ) { - Ok(()) => { - self.error = None; - self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey); - self.auth_manager.reload(); - *self.sign_in_state.write().unwrap() = SignInState::ApiKeyConfigured; - } - Err(err) => { - self.error = Some(format!("Failed to save API key: {err}")); - let mut guard = self.sign_in_state.write().unwrap(); - if let SignInState::ApiKeyEntry(existing) = &mut *guard { - if existing.value.is_empty() { - existing.value.push_str(&api_key); - } - existing.prepopulated_from_env = false; - } else { - *guard = SignInState::ApiKeyEntry(ApiKeyInputState { - value: api_key, - prepopulated_from_env: false, - }); - } - } + self.error = None; + if self + .auth_command_tx + .send(AuthCommand::StartApiKey { api_key }) + .is_err() + { + self.error = Some("Failed to start API key login".to_string()); } - self.request_frame.schedule_frame(); } @@ -714,75 +659,102 @@ impl AuthModeWidget { } } - /// Kicks off the ChatGPT auth flow and keeps the UI state consistent with the attempt. fn start_chatgpt_login(&mut self) { - // If we're already authenticated with ChatGPT, don't start a new login – - // just proceed to the success message flow. if self.handle_existing_chatgpt_login() { return; } self.error = None; - let opts = ServerOptions::new( - self.codex_home.clone(), - CLIENT_ID.to_string(), - self.forced_chatgpt_workspace_id.clone(), - self.cli_auth_credentials_store_mode, - ); - - match run_login_server(opts) { - Ok(child) => { - let sign_in_state = self.sign_in_state.clone(); - let request_frame = self.request_frame.clone(); - let auth_manager = self.auth_manager.clone(); - tokio::spawn(async move { - let auth_url = child.auth_url.clone(); - { - *sign_in_state.write().unwrap() = - SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { - auth_url, - shutdown_flag: Some(child.cancel_handle()), - }); - } - request_frame.schedule_frame(); - let r = child.block_until_done().await; - match r { - Ok(()) => { - // Force the auth manager to reload the new auth information. - auth_manager.reload(); - - *sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage; - request_frame.schedule_frame(); - } - _ => { - *sign_in_state.write().unwrap() = SignInState::PickMode; - // self.error = Some(e.to_string()); - request_frame.schedule_frame(); - } - } - }); - } - Err(e) => { - *self.sign_in_state.write().unwrap() = SignInState::PickMode; - self.error = Some(e.to_string()); - self.request_frame.schedule_frame(); - } + *self.sign_in_state.write().unwrap() = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + auth_url: String::new(), + login_id: None, + }); + if self + .auth_command_tx + .send(AuthCommand::StartChatgpt) + .is_err() + { + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + self.error = Some("Failed to start ChatGPT login".to_string()); } + self.request_frame.schedule_frame(); } - fn start_device_code_login(&mut self) { - if self.handle_existing_chatgpt_login() { - return; - } + pub(crate) fn apply_account(&mut self, account: Option<&Account>) { + self.login_status = login_status_from_account(account); + } + pub(crate) fn apply_chatgpt_login_started(&mut self, login_id: String, auth_url: String) { self.error = None; - let opts = ServerOptions::new( - self.codex_home.clone(), - CLIENT_ID.to_string(), - self.forced_chatgpt_workspace_id.clone(), - self.cli_auth_credentials_store_mode, - ); - headless_chatgpt_login::start_headless_chatgpt_login(self, opts); + *self.sign_in_state.write().unwrap() = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + auth_url, + login_id: Some(login_id), + }); + self.request_frame.schedule_frame(); + } + + pub(crate) fn apply_login_completed(&mut self, payload: AccountLoginCompletedNotification) { + match payload.login_id { + Some(login_id) => { + let mut guard = self.sign_in_state.write().unwrap(); + let is_active_login = matches!( + &*guard, + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + login_id: Some(active_login_id), + .. + }) if *active_login_id == login_id + ); + if !is_active_login { + return; + } + + if payload.success { + self.login_status = LoginStatus::AuthMode(AuthMode::Chatgpt); + *guard = SignInState::ChatGptSuccessMessage; + self.error = None; + } else { + *guard = SignInState::PickMode; + self.error = Some( + payload + .error + .unwrap_or_else(|| "ChatGPT sign-in failed".to_string()), + ); + } + } + None => { + let mut guard = self.sign_in_state.write().unwrap(); + if !matches!(&*guard, SignInState::ApiKeyEntry(_)) { + return; + } + + if payload.success { + self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey); + *guard = SignInState::ApiKeyConfigured; + self.error = None; + } else { + self.error = Some( + payload + .error + .map(|err| format!("Failed to save API key: {err}")) + .unwrap_or_else(|| "Failed to save API key".to_string()), + ); + } + } + } + self.request_frame.schedule_frame(); + } + + pub(crate) fn show_login_request_error(&mut self, message: String) { + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + self.error = Some(message); + self.request_frame.schedule_frame(); + } + + pub(crate) fn show_api_key_login_error(&mut self, message: String) { + self.error = Some(message); + self.request_frame.schedule_frame(); } } @@ -793,7 +765,6 @@ impl StepStateProvider for AuthModeWidget { SignInState::PickMode | SignInState::ApiKeyEntry(_) | SignInState::ChatGptContinueInBrowser(_) - | SignInState::ChatGptDeviceCode(_) | SignInState::ChatGptSuccessMessage => StepState::InProgress, SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete, } @@ -810,9 +781,6 @@ impl WidgetRef for AuthModeWidget { SignInState::ChatGptContinueInBrowser(_) => { self.render_continue_in_browser(area, buf); } - SignInState::ChatGptDeviceCode(state) => { - headless_chatgpt_login::render_device_code_login(self, area, buf, state); - } SignInState::ChatGptSuccessMessage => { self.render_chatgpt_success_message(area, buf); } @@ -832,37 +800,29 @@ impl WidgetRef for AuthModeWidget { #[cfg(test)] mod tests { use super::*; + use insta::assert_snapshot; use pretty_assertions::assert_eq; - use tempfile::TempDir; - use codex_core::auth::AuthCredentialsStoreMode; - - fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) { - let codex_home = TempDir::new().unwrap(); - let codex_home_path = codex_home.path().to_path_buf(); - let widget = AuthModeWidget { + fn auth_widget( + forced_login_method: Option, + highlighted_mode: SignInOption, + ) -> AuthModeWidget { + let (auth_command_tx, _auth_command_rx) = tokio::sync::mpsc::unbounded_channel(); + AuthModeWidget { + auth_command_tx, request_frame: FrameRequester::test_dummy(), - highlighted_mode: SignInOption::ChatGpt, + highlighted_mode, error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), - codex_home: codex_home_path.clone(), - cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, login_status: LoginStatus::NotAuthenticated, - auth_manager: AuthManager::shared( - codex_home_path, - false, - AuthCredentialsStoreMode::File, - ), - forced_chatgpt_workspace_id: None, - forced_login_method: Some(ForcedLoginMethod::Chatgpt), + forced_login_method, animations_enabled: true, - }; - (widget, codex_home) + } } #[test] fn api_key_flow_disabled_when_chatgpt_forced() { - let (mut widget, _tmp) = widget_forced_chatgpt(); + let mut widget = auth_widget(Some(ForcedLoginMethod::Chatgpt), SignInOption::ChatGpt); widget.start_api_key_entry(); @@ -875,7 +835,7 @@ mod tests { #[test] fn saving_api_key_is_blocked_when_chatgpt_forced() { - let (mut widget, _tmp) = widget_forced_chatgpt(); + let mut widget = auth_widget(Some(ForcedLoginMethod::Chatgpt), SignInOption::ChatGpt); widget.save_api_key("sk-test".to_string()); @@ -887,8 +847,22 @@ mod tests { assert_eq!(widget.login_status, LoginStatus::NotAuthenticated); } - /// Collects all buffer cell symbols that contain the OSC 8 open sequence - /// for the given URL. Returns the concatenated "inner" characters. + #[test] + fn device_code_login_shows_todo_error() { + let mut widget = auth_widget(None, SignInOption::DeviceCode); + + widget.handle_sign_in_option(SignInOption::DeviceCode); + + assert_eq!( + widget.error.as_deref(), + Some(DEVICE_CODE_UNAVAILABLE_MESSAGE) + ); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::PickMode + )); + } + fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String { let open = format!("\x1B]8;;{url}\x07"); let close = "\x1B]8;;\x07"; @@ -908,47 +882,52 @@ mod tests { #[test] fn continue_in_browser_renders_osc8_hyperlink() { - let (widget, _tmp) = widget_forced_chatgpt(); + let widget = auth_widget(Some(ForcedLoginMethod::Chatgpt), SignInOption::ChatGpt); let url = "https://auth.example.com/login?state=abc123"; *widget.sign_in_state.write().unwrap() = SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { auth_url: url.to_string(), - shutdown_flag: None, + login_id: Some("login-123".to_string()), }); - // Render into a narrow buffer so the URL wraps across multiple rows. let area = Rect::new(0, 0, 30, 20); let mut buf = Buffer::empty(area); widget.render_continue_in_browser(area, &mut buf); - // Every character of the URL should be present as an OSC 8 cell. let found = collect_osc8_chars(&buf, area, url); assert_eq!(found, url, "OSC 8 hyperlink should cover the full URL"); } + #[test] + fn device_code_login_unavailable_snapshot() { + let mut widget = auth_widget(None, SignInOption::DeviceCode); + widget.handle_sign_in_option(SignInOption::DeviceCode); + + let area = Rect::new(0, 0, 70, 18); + let mut buf = Buffer::empty(area); + widget.render_ref(area, &mut buf); + + assert_snapshot!("device_code_login_unavailable", format!("{buf:?}")); + } + #[test] fn mark_url_hyperlink_wraps_cyan_underlined_cells() { let url = "https://example.com"; let area = Rect::new(0, 0, 20, 1); let mut buf = Buffer::empty(area); - // Manually write some cyan+underlined characters to simulate a rendered URL. for (i, ch) in "example".chars().enumerate() { let cell = &mut buf[(i as u16, 0)]; cell.set_symbol(&ch.to_string()); cell.fg = Color::Cyan; cell.modifier = Modifier::UNDERLINED; } - // Leave a plain cell that should NOT be marked. buf[(7, 0)].set_symbol("X"); mark_url_hyperlink(&mut buf, area, url); - // Each cyan+underlined cell should now carry the OSC 8 wrapper. let found = collect_osc8_chars(&buf, area, url); assert_eq!(found, "example"); - - // The plain "X" cell should be untouched. assert_eq!(buf[(7, 0)].symbol(), "X"); } @@ -957,24 +936,20 @@ mod tests { let area = Rect::new(0, 0, 10, 1); let mut buf = Buffer::empty(area); - // One cyan+underlined cell to mark. let cell = &mut buf[(0, 0)]; cell.set_symbol("a"); cell.fg = Color::Cyan; cell.modifier = Modifier::UNDERLINED; - // URL contains ESC and BEL that could break the OSC 8 sequence. let malicious_url = "https://evil.com/\x1B]8;;\x07injected"; mark_url_hyperlink(&mut buf, area, malicious_url); let sym = buf[(0, 0)].symbol().to_string(); - // The sanitized URL retains `]` (printable) but strips ESC and BEL. let sanitized = "https://evil.com/]8;;injected"; assert!( sym.contains(sanitized), "symbol should contain sanitized URL, got: {sym:?}" ); - // The injected close-sequence must not survive: \x1B and \x07 are gone. assert!( !sym.contains("\x1B]8;;\x07injected"), "symbol must not contain raw control chars from URL" diff --git a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs deleted file mode 100644 index c8a6345843..0000000000 --- a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs +++ /dev/null @@ -1,387 +0,0 @@ -use codex_core::AuthManager; -use codex_login::ServerOptions; -use codex_login::complete_device_code_login; -use codex_login::request_device_code; -use codex_login::run_login_server; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::prelude::Widget; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Wrap; -use std::sync::Arc; -use std::sync::RwLock; -use tokio::sync::Notify; - -use crate::shimmer::shimmer_spans; -use crate::tui::FrameRequester; - -use super::AuthModeWidget; -use super::ContinueInBrowserState; -use super::ContinueWithDeviceCodeState; -use super::SignInState; -use super::mark_url_hyperlink; - -pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, mut opts: ServerOptions) { - opts.open_browser = false; - let sign_in_state = widget.sign_in_state.clone(); - let request_frame = widget.request_frame.clone(); - let auth_manager = widget.auth_manager.clone(); - let cancel = begin_device_code_attempt(&sign_in_state, &request_frame); - - tokio::spawn(async move { - let device_code = match request_device_code(&opts).await { - Ok(device_code) => device_code, - Err(err) => { - if err.kind() == std::io::ErrorKind::NotFound { - let should_fallback = { - let guard = sign_in_state.read().unwrap(); - device_code_attempt_matches(&guard, &cancel) - }; - - if !should_fallback { - return; - } - - match run_login_server(opts) { - Ok(child) => { - let auth_url = child.auth_url.clone(); - { - *sign_in_state.write().unwrap() = - SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { - auth_url, - shutdown_flag: Some(child.cancel_handle()), - }); - } - request_frame.schedule_frame(); - let r = child.block_until_done().await; - match r { - Ok(()) => { - auth_manager.reload(); - *sign_in_state.write().unwrap() = - SignInState::ChatGptSuccessMessage; - request_frame.schedule_frame(); - } - _ => { - *sign_in_state.write().unwrap() = SignInState::PickMode; - request_frame.schedule_frame(); - } - } - } - Err(_) => { - set_device_code_state_for_active_attempt( - &sign_in_state, - &request_frame, - &cancel, - SignInState::PickMode, - ); - } - } - } else { - set_device_code_state_for_active_attempt( - &sign_in_state, - &request_frame, - &cancel, - SignInState::PickMode, - ); - } - - return; - } - }; - - if !set_device_code_state_for_active_attempt( - &sign_in_state, - &request_frame, - &cancel, - SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { - device_code: Some(device_code.clone()), - cancel: Some(cancel.clone()), - }), - ) { - return; - } - - tokio::select! { - _ = cancel.notified() => {} - r = complete_device_code_login(opts, device_code) => { - match r { - Ok(()) => { - set_device_code_success_message_for_active_attempt( - &sign_in_state, - &request_frame, - &auth_manager, - &cancel, - ); - } - Err(_) => { - set_device_code_state_for_active_attempt( - &sign_in_state, - &request_frame, - &cancel, - SignInState::PickMode, - ); - } - } - } - } - }); -} - -pub(super) fn render_device_code_login( - widget: &AuthModeWidget, - area: Rect, - buf: &mut Buffer, - state: &ContinueWithDeviceCodeState, -) { - let banner = if state.device_code.is_some() { - "Finish signing in via your browser" - } else { - "Preparing device code login" - }; - - let mut spans = vec![" ".into()]; - if widget.animations_enabled { - // Schedule a follow-up frame to keep the shimmer animation going. - widget - .request_frame - .schedule_frame_in(std::time::Duration::from_millis(100)); - spans.extend(shimmer_spans(banner)); - } else { - spans.push(banner.into()); - } - - let mut lines = vec![spans.into(), "".into()]; - - // Capture the verification URL for OSC 8 hyperlink marking after render. - let verification_url = if let Some(device_code) = &state.device_code { - lines.push(" 1. Open this link in your browser and sign in".into()); - lines.push("".into()); - lines.push(Line::from(vec![ - " ".into(), - device_code.verification_url.as_str().cyan().underlined(), - ])); - lines.push("".into()); - lines.push( - " 2. Enter this one-time code after you are signed in (expires in 15 minutes)".into(), - ); - lines.push("".into()); - lines.push(Line::from(vec![ - " ".into(), - device_code.user_code.as_str().cyan().bold(), - ])); - lines.push("".into()); - lines.push( - " Device codes are a common phishing target. Never share this code." - .dim() - .into(), - ); - lines.push("".into()); - Some(device_code.verification_url.clone()) - } else { - lines.push(" Requesting a one-time code...".dim().into()); - lines.push("".into()); - None - }; - - lines.push(" Press Esc to cancel".dim().into()); - Paragraph::new(lines) - .wrap(Wrap { trim: false }) - .render(area, buf); - - // Wrap cyan+underlined URL cells with OSC 8 so the terminal treats - // the entire region as a single clickable hyperlink. - if let Some(url) = &verification_url { - mark_url_hyperlink(buf, area, url); - } -} - -fn device_code_attempt_matches(state: &SignInState, cancel: &Arc) -> bool { - matches!( - state, - SignInState::ChatGptDeviceCode(state) - if state - .cancel - .as_ref() - .is_some_and(|existing| Arc::ptr_eq(existing, cancel)) - ) -} - -fn begin_device_code_attempt( - sign_in_state: &Arc>, - request_frame: &FrameRequester, -) -> Arc { - let cancel = Arc::new(Notify::new()); - *sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { - device_code: None, - cancel: Some(cancel.clone()), - }); - request_frame.schedule_frame(); - cancel -} - -fn set_device_code_state_for_active_attempt( - sign_in_state: &Arc>, - request_frame: &FrameRequester, - cancel: &Arc, - next_state: SignInState, -) -> bool { - let mut guard = sign_in_state.write().unwrap(); - if !device_code_attempt_matches(&guard, cancel) { - return false; - } - - *guard = next_state; - drop(guard); - request_frame.schedule_frame(); - true -} - -fn set_device_code_success_message_for_active_attempt( - sign_in_state: &Arc>, - request_frame: &FrameRequester, - auth_manager: &AuthManager, - cancel: &Arc, -) -> bool { - let mut guard = sign_in_state.write().unwrap(); - if !device_code_attempt_matches(&guard, cancel) { - return false; - } - - auth_manager.reload(); - *guard = SignInState::ChatGptSuccessMessage; - drop(guard); - request_frame.schedule_frame(); - true -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_core::auth::AuthCredentialsStoreMode; - use pretty_assertions::assert_eq; - use tempfile::TempDir; - - fn device_code_sign_in_state(cancel: Arc) -> Arc> { - Arc::new(RwLock::new(SignInState::ChatGptDeviceCode( - ContinueWithDeviceCodeState { - device_code: None, - cancel: Some(cancel), - }, - ))) - } - - #[test] - fn device_code_attempt_matches_only_for_matching_cancel() { - let cancel = Arc::new(Notify::new()); - let state = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { - device_code: None, - cancel: Some(cancel.clone()), - }); - - assert_eq!(device_code_attempt_matches(&state, &cancel), true); - assert_eq!( - device_code_attempt_matches(&state, &Arc::new(Notify::new())), - false - ); - assert_eq!( - device_code_attempt_matches(&SignInState::PickMode, &cancel), - false - ); - } - - #[test] - fn begin_device_code_attempt_sets_state() { - let sign_in_state = Arc::new(RwLock::new(SignInState::PickMode)); - let request_frame = FrameRequester::test_dummy(); - - let cancel = begin_device_code_attempt(&sign_in_state, &request_frame); - let guard = sign_in_state.read().unwrap(); - - let state: &SignInState = &guard; - assert_eq!(device_code_attempt_matches(state, &cancel), true); - assert!(matches!( - state, - SignInState::ChatGptDeviceCode(state) if state.device_code.is_none() - )); - } - - #[test] - fn set_device_code_state_for_active_attempt_updates_only_when_active() { - let request_frame = FrameRequester::test_dummy(); - let cancel = Arc::new(Notify::new()); - let sign_in_state = device_code_sign_in_state(cancel.clone()); - - assert_eq!( - set_device_code_state_for_active_attempt( - &sign_in_state, - &request_frame, - &cancel, - SignInState::PickMode, - ), - true - ); - assert!(matches!( - &*sign_in_state.read().unwrap(), - SignInState::PickMode - )); - - let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new())); - assert_eq!( - set_device_code_state_for_active_attempt( - &sign_in_state, - &request_frame, - &cancel, - SignInState::PickMode, - ), - false - ); - assert!(matches!( - &*sign_in_state.read().unwrap(), - SignInState::ChatGptDeviceCode(_) - )); - } - - #[test] - fn set_device_code_success_message_for_active_attempt_updates_only_when_active() { - let request_frame = FrameRequester::test_dummy(); - let cancel = Arc::new(Notify::new()); - let sign_in_state = device_code_sign_in_state(cancel.clone()); - let temp_dir = TempDir::new().unwrap(); - let auth_manager = AuthManager::shared( - temp_dir.path().to_path_buf(), - false, - AuthCredentialsStoreMode::File, - ); - - assert_eq!( - set_device_code_success_message_for_active_attempt( - &sign_in_state, - &request_frame, - &auth_manager, - &cancel, - ), - true - ); - assert!(matches!( - &*sign_in_state.read().unwrap(), - SignInState::ChatGptSuccessMessage - )); - - let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new())); - assert_eq!( - set_device_code_success_message_for_active_attempt( - &sign_in_state, - &request_frame, - &auth_manager, - &cancel, - ), - false - ); - assert!(matches!( - &*sign_in_state.read().unwrap(), - SignInState::ChatGptDeviceCode(_) - )); - } -} diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index d4cfd6d1f4..4d032ad42b 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod account_login; mod auth; pub mod onboarding_screen; mod trust_directory; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 127ac9f667..1faee14670 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,4 +1,7 @@ -use codex_core::AuthManager; +use codex_app_server_client::InProcessAppServerClient; +use codex_app_server_client::InProcessServerEvent; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::ServerNotification; use codex_core::config::Config; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; @@ -17,6 +20,8 @@ use ratatui::widgets::WidgetRef; use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; +use crate::onboarding::account_login::AuthCommand; +use crate::onboarding::account_login::OnboardingAccountApi; use crate::onboarding::auth::AuthModeWidget; use crate::onboarding::auth::SignInOption; use crate::onboarding::auth::SignInState; @@ -27,8 +32,10 @@ use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; use color_eyre::eyre::Result; +use color_eyre::eyre::eyre; use std::sync::Arc; use std::sync::RwLock; +use tokio::sync::mpsc; #[allow(clippy::large_enum_variant)] enum Step { @@ -64,7 +71,6 @@ pub(crate) struct OnboardingScreenArgs { pub show_trust_screen: bool, pub show_login_screen: bool, pub login_status: LoginStatus, - pub auth_manager: Arc, pub config: Config, } @@ -74,19 +80,20 @@ pub(crate) struct OnboardingResult { } impl OnboardingScreen { - pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self { + pub(crate) fn new( + tui: &mut Tui, + args: OnboardingScreenArgs, + auth_command_tx: Option>, + ) -> Self { let OnboardingScreenArgs { show_trust_screen, show_login_screen, login_status, - auth_manager, config, } = args; let cwd = config.cwd.clone(); - let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); - let forced_login_method = config.forced_login_method; let codex_home = config.codex_home.clone(); - let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode; + let forced_login_method = config.forced_login_method; let mut steps: Vec = Vec::new(); steps.push(Step::Welcome(WelcomeWidget::new( !matches!(login_status, LoginStatus::NotAuthenticated), @@ -98,16 +105,16 @@ impl OnboardingScreen { Some(ForcedLoginMethod::Api) => SignInOption::ApiKey, _ => SignInOption::ChatGpt, }; + let Some(auth_command_tx) = auth_command_tx else { + unreachable!("auth command sender should exist when login screen is shown"); + }; steps.push(Step::Auth(AuthModeWidget { + auth_command_tx, request_frame: tui.frame_requester(), highlighted_mode, error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), - codex_home: codex_home.clone(), - cli_auth_credentials_store_mode, login_status, - auth_manager, - forced_chatgpt_workspace_id, forced_login_method, animations_enabled: config.animations, })) @@ -210,6 +217,13 @@ impl OnboardingScreen { false }) } + + fn auth_widget_mut(&mut self) -> Option<&mut AuthModeWidget> { + self.steps.iter_mut().find_map(|step| match step { + Step::Auth(widget) => Some(widget), + Step::Welcome(_) | Step::TrustDirectory(_) => None, + }) + } } impl KeyboardHandler for OnboardingScreen { @@ -395,10 +409,14 @@ impl WidgetRef for Step { pub(crate) async fn run_onboarding_app( args: OnboardingScreenArgs, tui: &mut Tui, + app_server: Option<&mut InProcessAppServerClient>, ) -> Result { use tokio_stream::StreamExt; - let mut onboarding_screen = OnboardingScreen::new(tui, args); + let show_login_screen = args.show_login_screen; + let (auth_command_tx, mut auth_command_rx) = mpsc::unbounded_channel(); + let mut onboarding_screen = + OnboardingScreen::new(tui, args, show_login_screen.then_some(auth_command_tx)); // One-time guard to fully clear the screen after ChatGPT login success message is shown let mut did_full_clear_after_success = false; @@ -408,56 +426,178 @@ pub(crate) async fn run_onboarding_app( let tui_events = tui.event_stream(); tokio::pin!(tui_events); + let mut account_api = OnboardingAccountApi::default(); - while !onboarding_screen.is_done() { - if let Some(event) = tui_events.next().await { - match event { - TuiEvent::Key(key_event) => { - onboarding_screen.handle_key_event(key_event); + if show_login_screen { + let app_server = app_server.ok_or_else(|| eyre!("missing app server for onboarding"))?; + while !onboarding_screen.is_done() { + tokio::select! { + Some(event) = tui_events.next() => { + handle_tui_event( + event, + tui, + &mut onboarding_screen, + &mut did_full_clear_after_success, + ); } - TuiEvent::Paste(text) => { - onboarding_screen.handle_paste(text); + Some(command) = auth_command_rx.recv() => { + handle_auth_command( + command, + app_server, + &mut account_api, + &mut onboarding_screen, + ).await; } - TuiEvent::Draw => { - if !did_full_clear_after_success - && onboarding_screen.steps.iter().any(|step| { - if let Step::Auth(w) = step { - w.sign_in_state.read().is_ok_and(|g| { - matches!(&*g, super::auth::SignInState::ChatGptSuccessMessage) - }) - } else { - false - } - }) - { - // Reset any lingering SGR (underline/color) before clearing - let _ = ratatui::crossterm::execute!( - std::io::stdout(), - ratatui::crossterm::style::SetAttribute( - ratatui::crossterm::style::Attribute::Reset - ), - ratatui::crossterm::style::SetAttribute( - ratatui::crossterm::style::Attribute::NoUnderline - ), - ratatui::crossterm::style::SetForegroundColor( - ratatui::crossterm::style::Color::Reset - ), - ratatui::crossterm::style::SetBackgroundColor( - ratatui::crossterm::style::Color::Reset - ) - ); - let _ = tui.terminal.clear(); - did_full_clear_after_success = true; + app_server_event = app_server.next_event() => { + if let Some(event) = app_server_event { + handle_app_server_event( + event, + app_server, + &mut account_api, + &mut onboarding_screen, + ).await; + } else { + break; } - let _ = tui.draw(u16::MAX, |frame| { - frame.render_widget_ref(&onboarding_screen, frame.area()); - }); } } } + } else { + while !onboarding_screen.is_done() { + if let Some(event) = tui_events.next().await { + handle_tui_event( + event, + tui, + &mut onboarding_screen, + &mut did_full_clear_after_success, + ); + } + } } + Ok(OnboardingResult { directory_trust_decision: onboarding_screen.directory_trust_decision(), should_exit: onboarding_screen.should_exit(), }) } + +fn handle_tui_event( + event: TuiEvent, + tui: &mut Tui, + onboarding_screen: &mut OnboardingScreen, + did_full_clear_after_success: &mut bool, +) { + match event { + TuiEvent::Key(key_event) => { + onboarding_screen.handle_key_event(key_event); + } + TuiEvent::Paste(text) => { + onboarding_screen.handle_paste(text); + } + TuiEvent::Draw => { + if !*did_full_clear_after_success + && onboarding_screen.steps.iter().any(|step| { + if let Step::Auth(w) = step { + w.sign_in_state.read().is_ok_and(|g| { + matches!(&*g, super::auth::SignInState::ChatGptSuccessMessage) + }) + } else { + false + } + }) + { + let _ = ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::style::SetAttribute( + ratatui::crossterm::style::Attribute::Reset + ), + ratatui::crossterm::style::SetAttribute( + ratatui::crossterm::style::Attribute::NoUnderline + ), + ratatui::crossterm::style::SetForegroundColor( + ratatui::crossterm::style::Color::Reset + ), + ratatui::crossterm::style::SetBackgroundColor( + ratatui::crossterm::style::Color::Reset + ) + ); + let _ = tui.terminal.clear(); + *did_full_clear_after_success = true; + } + let _ = tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&*onboarding_screen, frame.area()); + }); + } + } +} + +async fn handle_auth_command( + command: AuthCommand, + app_server: &InProcessAppServerClient, + account_api: &mut OnboardingAccountApi, + onboarding_screen: &mut OnboardingScreen, +) { + match command { + AuthCommand::StartApiKey { api_key } => { + if let Err(err) = account_api.start_api_key_login(app_server, api_key).await + && let Some(auth_widget) = onboarding_screen.auth_widget_mut() + { + auth_widget.show_api_key_login_error(format!("Failed to save API key: {err}")); + } + } + AuthCommand::StartChatgpt => { + let result = account_api.start_chatgpt_login(app_server).await; + if let Some(auth_widget) = onboarding_screen.auth_widget_mut() { + match result { + Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => { + auth_widget.apply_chatgpt_login_started(login_id, auth_url); + } + Ok(response) => { + auth_widget.show_login_request_error(format!( + "Unexpected account/login/start response: {response:?}" + )); + } + Err(err) => { + auth_widget.show_login_request_error(err); + } + } + } + } + AuthCommand::CancelChatgpt { login_id } => { + if let Err(err) = account_api.cancel_chatgpt_login(app_server, login_id).await { + tracing::warn!("failed to cancel onboarding login: {err}"); + } + } + } +} + +async fn handle_app_server_event( + event: InProcessServerEvent, + app_server: &InProcessAppServerClient, + account_api: &mut OnboardingAccountApi, + onboarding_screen: &mut OnboardingScreen, +) { + match event { + InProcessServerEvent::ServerNotification(ServerNotification::AccountUpdated(_)) => { + match account_api.read_account(app_server).await { + Ok(response) => { + if let Some(auth_widget) = onboarding_screen.auth_widget_mut() { + auth_widget.apply_account(response.account.as_ref()); + } + } + Err(err) => tracing::warn!("failed to refresh onboarding account state: {err}"), + } + } + InProcessServerEvent::ServerNotification(ServerNotification::AccountLoginCompleted( + payload, + )) => { + if let Some(auth_widget) = onboarding_screen.auth_widget_mut() { + auth_widget.apply_login_completed(payload); + } + } + InProcessServerEvent::LegacyNotification(_) + | InProcessServerEvent::ServerNotification(_) + | InProcessServerEvent::ServerRequest(_) + | InProcessServerEvent::Lagged { .. } => {} + } +} diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__tests__device_code_login_unavailable.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__tests__device_code_login_unavailable.snap new file mode 100644 index 0000000000..8611af9d4c --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__tests__device_code_login_unavailable.snap @@ -0,0 +1,45 @@ +--- +source: tui/src/onboarding/auth.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 70, height: 18 }, + content: [ + " Sign in with ChatGPT to use Codex as part of your paid plan ", + " or connect an API key for usage-based billing ", + " ", + " 1. Sign in with ChatGPT ", + " Usage included with Plus, Pro, Business, and Enterprise plans ", + " ", + "> 2. Sign in with Device Code ", + " Sign in from another device with a one-time code ", + " ", + " 3. Provide your own API key ", + " Pay for what you use ", + " ", + " Press Enter to continue ", + " ", + "Device code sign-in is not yet available through the app-server ", + "onboarding flow. Use browser sign-in instead. ", + " ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 66, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: DIM, + x: 5, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 7, fg: Cyan, bg: Reset, underline: Reset, modifier: DIM, + x: 53, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 25, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 25, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 14, fg: Red, bg: Reset, underline: Reset, modifier: NONE, + x: 63, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 15, fg: Red, bg: Reset, underline: Reset, modifier: NONE, + x: 45, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +}