diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 80809434e3..7e0e195c4c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -971,6 +971,7 @@ dependencies = [ "unicode-width 0.1.14", "uuid", "vt100", + "webbrowser", ] [[package]] diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 78a6a77821..f55321ed31 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -33,18 +33,19 @@ codex-common = { path = "../common", features = [ "sandbox_summary", ] } codex-core = { path = "../core" } -codex-protocol = { path = "../protocol" } codex-file-search = { path = "../file-search" } codex-login = { path = "../login" } codex-ollama = { path = "../ollama" } +codex-protocol = { path = "../protocol" } color-eyre = "0.6.3" crossterm = { version = "0.28.1", features = ["bracketed-paste"] } diffy = "0.4.2" image = { version = "^0.25.6", default-features = false, features = ["jpeg"] } lazy_static = "1" -once_cell = "1" mcp-types = { path = "../mcp-types" } +once_cell = "1" path-clean = "1.0.1" +rand = "0.9" ratatui = { version = "0.29.0", features = [ "scrolling-regions", "unstable-rendered-line-info", @@ -75,7 +76,7 @@ tui-markdown = "0.3.3" unicode-segmentation = "1.12.0" unicode-width = "0.1" uuid = "1" -rand = "0.9" +webbrowser = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ffb3d7cacb..4307296484 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -139,10 +139,11 @@ impl App<'_> { } let login_status = get_login_status(&config); + tracing::info!("login_status1: {:?}", login_status); let should_show_onboarding = - should_show_onboarding(login_status, &config, show_trust_screen); + should_show_onboarding(&login_status, &config, show_trust_screen); let app_state = if should_show_onboarding { - let show_login_screen = should_show_login_screen(login_status, &config); + let show_login_screen = should_show_login_screen(&login_status, &config); let chat_widget_args = ChatWidgetArgs { config: config.clone(), initial_prompt, @@ -640,7 +641,7 @@ impl App<'_> { } fn should_show_onboarding( - login_status: LoginStatus, + login_status: &LoginStatus, config: &Config, show_trust_screen: bool, ) -> bool { @@ -648,13 +649,26 @@ fn should_show_onboarding( return true; } + if is_free_plan(login_status) { + return true; + } + should_show_login_screen(login_status, config) } -fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool { +fn is_free_plan(login_status: &LoginStatus) -> bool { + match login_status { + LoginStatus::Auth(auth) => auth.get_plan_type().as_deref() == Some("free"), + LoginStatus::NotAuthenticated => false, + } +} + +fn should_show_login_screen(login_status: &LoginStatus, config: &Config) -> bool { match login_status { LoginStatus::NotAuthenticated => true, - LoginStatus::AuthMode(method) => method != config.preferred_auth_method, + LoginStatus::Auth(auth) => { + auth.mode != config.preferred_auth_method || is_free_plan(login_status) + } } } @@ -664,6 +678,7 @@ mod tests { use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; use codex_login::AuthMode; + use codex_login::CodexAuth; fn make_config(preferred: AuthMode) -> Config { let mut cfg = Config::load_from_base_config_with_overrides( @@ -680,7 +695,7 @@ mod tests { fn shows_login_when_not_authenticated() { let cfg = make_config(AuthMode::ChatGPT); assert!(should_show_login_screen( - LoginStatus::NotAuthenticated, + &LoginStatus::NotAuthenticated, &cfg )); } @@ -689,7 +704,7 @@ mod tests { fn shows_login_when_api_key_but_prefers_chatgpt() { let cfg = make_config(AuthMode::ChatGPT); assert!(should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ApiKey), + &LoginStatus::Auth(CodexAuth::from_api_key("sk-test")), &cfg )) } @@ -698,7 +713,7 @@ mod tests { fn hides_login_when_api_key_and_prefers_api_key() { let cfg = make_config(AuthMode::ApiKey); assert!(!should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ApiKey), + &LoginStatus::Auth(CodexAuth::from_api_key("sk-test")), &cfg )) } @@ -707,7 +722,7 @@ mod tests { fn hides_login_when_chatgpt_and_prefers_chatgpt() { let cfg = make_config(AuthMode::ChatGPT); assert!(!should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ChatGPT), + &LoginStatus::Auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()), &cfg )) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0f8b2242cb..614934dca1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -12,7 +12,6 @@ use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_login::AuthMode; use codex_login::CodexAuth; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; @@ -297,9 +296,9 @@ fn restore() { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum LoginStatus { - AuthMode(AuthMode), + Auth(CodexAuth), NotAuthenticated, } @@ -309,7 +308,7 @@ fn get_login_status(config: &Config) -> LoginStatus { // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); match CodexAuth::from_codex_home(&codex_home, config.preferred_auth_method) { - Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode), + Ok(Some(auth)) => LoginStatus::Auth(auth), Ok(None) => LoginStatus::NotAuthenticated, Err(err) => { error!("Failed to read auth.json: {err}"); diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 2396b4d5fa..46fd7a7f82 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -29,6 +29,8 @@ use std::path::PathBuf; use super::onboarding_screen::StepState; // no additional imports +use codex_login::logout; +const PRICING_URL: &str = "https://openai.com/chatgpt/pricing"; #[derive(Debug)] pub(crate) enum SignInState { @@ -36,10 +38,18 @@ pub(crate) enum SignInState { ChatGptContinueInBrowser(ContinueInBrowserState), ChatGptSuccessMessage, ChatGptSuccess, + FreePlan, EnvVarMissing, EnvVarFound, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FreePlanSelection { + Upgrade, + Logout, + Exit, +} + #[derive(Debug)] /// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up. pub(crate) struct ContinueInBrowserState { @@ -59,12 +69,30 @@ impl Drop for ContinueInBrowserState { impl KeyboardHandler for AuthModeWidget { fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event.code { - KeyCode::Up | KeyCode::Char('k') => { - self.highlighted_mode = AuthMode::ChatGPT; - } - KeyCode::Down | KeyCode::Char('j') => { - self.highlighted_mode = AuthMode::ApiKey; - } + KeyCode::Up | KeyCode::Char('k') => match self.sign_in_state { + SignInState::FreePlan => { + self.free_plan_selected = match self.free_plan_selected { + FreePlanSelection::Upgrade => FreePlanSelection::Exit, + FreePlanSelection::Logout => FreePlanSelection::Upgrade, + FreePlanSelection::Exit => FreePlanSelection::Logout, + }; + } + _ => { + self.highlighted_mode = AuthMode::ChatGPT; + } + }, + KeyCode::Down | KeyCode::Char('j') => match self.sign_in_state { + SignInState::FreePlan => { + self.free_plan_selected = match self.free_plan_selected { + FreePlanSelection::Upgrade => FreePlanSelection::Logout, + FreePlanSelection::Logout => FreePlanSelection::Exit, + FreePlanSelection::Exit => FreePlanSelection::Upgrade, + }; + } + _ => { + self.highlighted_mode = AuthMode::ApiKey; + } + }, KeyCode::Char('1') => { self.start_chatgpt_login(); } @@ -78,6 +106,22 @@ impl KeyboardHandler for AuthModeWidget { SignInState::ChatGptSuccessMessage => { self.sign_in_state = SignInState::ChatGptSuccess } + SignInState::FreePlan => match self.free_plan_selected { + FreePlanSelection::Upgrade => { + let _ = webbrowser::open(PRICING_URL); + let _ = logout(&self.codex_home); + self.login_status = LoginStatus::NotAuthenticated; + self.event_tx.send(AppEvent::ExitRequest); + } + FreePlanSelection::Logout => { + let _ = logout(&self.codex_home); + self.login_status = LoginStatus::NotAuthenticated; + self.sign_in_state = SignInState::PickMode; + } + FreePlanSelection::Exit => { + self.event_tx.send(AppEvent::ExitRequest); + } + }, _ => {} }, KeyCode::Esc => { @@ -99,6 +143,7 @@ pub(crate) struct AuthModeWidget { pub codex_home: PathBuf, pub login_status: LoginStatus, pub preferred_auth_method: AuthMode, + pub free_plan_selected: FreePlanSelection, } impl AuthModeWidget { @@ -123,15 +168,15 @@ impl AuthModeWidget { // If the user is already authenticated but the method differs from their // preferred auth method, show a brief explanation. - if let LoginStatus::AuthMode(current) = self.login_status { - if current != self.preferred_auth_method { + if let LoginStatus::Auth(ref current) = self.login_status { + if current.mode != self.preferred_auth_method { let to_label = |mode: AuthMode| match mode { AuthMode::ApiKey => "API key", AuthMode::ChatGPT => "ChatGPT", }; let msg = format!( " You’re currently using {} while your preferred method is {}.", - to_label(current), + to_label(current.mode), to_label(self.preferred_auth_method) ); lines.push(Line::from(msg).style(Style::default())); @@ -167,11 +212,9 @@ impl AuthModeWidget { vec![line1, line2] }; - let chatgpt_label = if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) - { - "Continue using ChatGPT" - } else { - "Sign in with ChatGPT" + let chatgpt_label = match &self.login_status { + LoginStatus::Auth(auth) if auth.mode == AuthMode::ChatGPT => "Continue using ChatGPT", + _ => "Sign in with ChatGPT", }; lines.extend(create_mode_item( @@ -180,11 +223,9 @@ impl AuthModeWidget { chatgpt_label, "Usage included with Plus, Pro, and Team plans", )); - let api_key_label = if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ApiKey)) - { - "Continue using API key" - } else { - "Provide your own API key" + let api_key_label = match &self.login_status { + LoginStatus::Auth(auth) if auth.mode == AuthMode::ApiKey => "Continue using API key", + _ => "Provide your own API key", }; lines.extend(create_mode_item( 1, @@ -287,6 +328,53 @@ impl AuthModeWidget { .render(area, buf); } + fn render_free_plan(&self, area: Rect, buf: &mut Buffer) { + let mut lines: Vec = vec![ + Line::from("> You're currently signed in using a free ChatGPT account"), + Line::from(""), + Line::from( + " To use Codex with your ChatGPT plan, upgrade to a Pro, Plus, and Team account.", + ), + Line::from(""), + Line::from(vec![ + Span::raw(" "), + "\u{1b}]8;;https://openai.com/chatgpt/pricing\u{7}https://openai.com/chatgpt/pricing\u{1b}]8;;\u{7}" + .underlined(), + ]), + Line::from(""), + ]; + + let option_line = |idx: usize, label: &str, selected: bool| { + if selected { + Line::from(format!("> {idx}. {label}")).fg(Color::Cyan) + } else { + Line::from(format!(" {idx}. {label}")) + } + }; + + lines.push(option_line( + 1, + "Upgrade plan", + matches!(self.free_plan_selected, FreePlanSelection::Upgrade), + )); + lines.push(option_line( + 2, + "Log out to use a different account", + matches!(self.free_plan_selected, FreePlanSelection::Logout), + )); + lines.push(option_line( + 3, + "Exit", + matches!(self.free_plan_selected, FreePlanSelection::Exit), + )); + lines.push(Line::from("")); + lines.push(Line::from(" Press Enter to confirm").add_modifier(Modifier::DIM)); + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + fn render_env_var_found(&self, area: Rect, buf: &mut Buffer) { let lines = vec![Line::from("✓ Using OPENAI_API_KEY").fg(Color::Green)]; @@ -314,10 +402,16 @@ impl AuthModeWidget { 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 matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { - self.sign_in_state = SignInState::ChatGptSuccess; - self.event_tx.send(AppEvent::RequestRedraw); - return; + if let LoginStatus::Auth(auth) = &self.login_status { + if auth.mode == AuthMode::ChatGPT { + if auth.get_plan_type().as_deref() == Some("free") { + self.sign_in_state = SignInState::FreePlan; + } else { + self.sign_in_state = SignInState::ChatGptSuccess; + } + self.event_tx.send(AppEvent::RequestRedraw); + return; + } } self.error = None; @@ -350,12 +444,15 @@ impl AuthModeWidget { /// TODO: Read/write from the correct hierarchy config overrides + auth json + OPENAI_API_KEY. fn verify_api_key(&mut self) { - if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ApiKey)) { - // We already have an API key configured (e.g., from auth.json or env), - // so mark this step complete immediately. - self.sign_in_state = SignInState::EnvVarFound; - } else { - self.sign_in_state = SignInState::EnvVarMissing; + match &self.login_status { + LoginStatus::Auth(auth) if auth.mode == AuthMode::ApiKey => { + // We already have an API key configured (e.g., from auth.json or env), + // so mark this step complete immediately. + self.sign_in_state = SignInState::EnvVarFound; + } + _ => { + self.sign_in_state = SignInState::EnvVarMissing; + } } self.event_tx.send(AppEvent::RequestRedraw); @@ -383,7 +480,8 @@ impl StepStateProvider for AuthModeWidget { SignInState::PickMode | SignInState::EnvVarMissing | SignInState::ChatGptContinueInBrowser(_) - | SignInState::ChatGptSuccessMessage => StepState::InProgress, + | SignInState::ChatGptSuccessMessage + | SignInState::FreePlan => StepState::InProgress, SignInState::ChatGptSuccess | SignInState::EnvVarFound => StepState::Complete, } } @@ -404,6 +502,9 @@ impl WidgetRef for AuthModeWidget { SignInState::ChatGptSuccess => { self.render_chatgpt_success(area, buf); } + SignInState::FreePlan => { + self.render_free_plan(area, buf); + } SignInState::EnvVarMissing => { self.render_env_var_missing(area, buf); } diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index c116936851..e1868a73eb 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,5 +1,6 @@ mod auth; mod continue_to_chat; +mod free_plan_screen; pub mod onboarding_screen; mod trust_directory; mod welcome; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index ea473512d0..255368250e 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -18,6 +18,7 @@ use crate::onboarding::continue_to_chat::ContinueToChatWidget; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; use crate::onboarding::welcome::WelcomeWidget; + use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -28,6 +29,7 @@ enum Step { Auth(AuthModeWidget), TrustDirectory(TrustDirectoryWidget), ContinueToChat(ContinueToChatWidget), + // Free plan message is now handled as an Auth sign-in state } pub(crate) trait KeyboardHandler { @@ -73,6 +75,8 @@ impl OnboardingScreen { let mut steps: Vec = vec![Step::Welcome(WelcomeWidget { is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated), })]; + // + if show_login_screen { steps.push(Step::Auth(AuthModeWidget { event_tx: event_tx.clone(), @@ -82,6 +86,7 @@ impl OnboardingScreen { codex_home: codex_home.clone(), login_status, preferred_auth_method: chat_widget_args.config.preferred_auth_method, + free_plan_selected: crate::onboarding::auth::FreePlanSelection::Upgrade, })) } let is_git_repo = is_inside_git_repo(&cwd); @@ -118,16 +123,30 @@ impl OnboardingScreen { if let Some(Step::Auth(state)) = current_step { match result { Ok(()) => { - state.sign_in_state = SignInState::ChatGptSuccessMessage; - self.event_tx.send(AppEvent::RequestRedraw); - let tx1 = self.event_tx.clone(); - let tx2 = self.event_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(150)); - tx1.send(AppEvent::RequestRedraw); - std::thread::sleep(std::time::Duration::from_millis(200)); - tx2.send(AppEvent::RequestRedraw); - }); + // After login, if the plan is free, show the Free Plan state inside Auth. + let is_free = match codex_login::CodexAuth::from_codex_home( + &state.codex_home, + state.preferred_auth_method, + ) { + Ok(Some(auth)) => auth.get_plan_type().as_deref() == Some("free"), + _ => false, + }; + + if is_free { + state.sign_in_state = SignInState::FreePlan; + self.event_tx.send(AppEvent::RequestRedraw); + } else { + state.sign_in_state = SignInState::ChatGptSuccessMessage; + self.event_tx.send(AppEvent::RequestRedraw); + let tx1 = self.event_tx.clone(); + let tx2 = self.event_tx.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(150)); + tx1.send(AppEvent::RequestRedraw); + std::thread::sleep(std::time::Duration::from_millis(200)); + tx2.send(AppEvent::RequestRedraw); + }); + } } Err(e) => { state.sign_in_state = SignInState::PickMode; @@ -282,3 +301,5 @@ impl WidgetRef for Step { } } } + +// Note: free-plan handling now occurs within the Auth sign-in state.