Compare commits

...

11 Commits

Author SHA1 Message Date
Ahmed Ibrahim
ec288958e9 upgrade 2025-08-19 17:09:06 -07:00
Ahmed Ibrahim
bc6368cff9 Merge branch 'main' into nux 2025-08-19 17:05:46 -07:00
Ahmed Ibrahim
ee761180ea clippy 2025-08-19 16:26:04 -07:00
Ahmed Ibrahim
c21f3346ef free plan screen 2025-08-19 15:49:04 -07:00
Ahmed Ibrahim
8382dbc12e Merge branch 'main' into nux 2025-08-19 15:48:03 -07:00
Ahmed Ibrahim
ce804c62a6 free plan screen 2025-08-19 15:46:42 -07:00
Ahmed Ibrahim
b412117929 free plan screen 2025-08-19 15:45:28 -07:00
Ahmed Ibrahim
7759f1bc13 edits 2025-08-19 15:22:25 -07:00
Ahmed Ibrahim
340270d1fa edits 2025-08-19 15:21:44 -07:00
Ahmed Ibrahim
29131b0d88 edits 2025-08-19 15:14:31 -07:00
Ahmed Ibrahim
a3a2b1c88c working 2025-08-19 15:13:18 -07:00
6 changed files with 189 additions and 55 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -970,6 +970,7 @@ dependencies = [
"unicode-width 0.1.14",
"uuid",
"vt100",
"webbrowser",
]
[[package]]

View File

@@ -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"

View File

@@ -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,10 +649,21 @@ 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 {
// Only show the login screen for providers that actually require OpenAI auth
// (OpenAI or equivalents). For OSS/other providers, skip login entirely.
if !config.model_provider.requires_openai_auth {
@@ -660,7 +672,9 @@ 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)
}
}
}
@@ -670,6 +684,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(
@@ -686,7 +701,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
));
}
@@ -695,7 +710,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
))
}
@@ -704,7 +719,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
))
}
@@ -713,7 +728,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
))
}

View File

@@ -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}");

View File

@@ -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,8 +168,8 @@ 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
&& current != self.preferred_auth_method
if let LoginStatus::Auth(ref current) = self.login_status
&& current.mode != self.preferred_auth_method
{
let to_label = |mode: AuthMode| match mode {
AuthMode::ApiKey => "API key",
@@ -132,7 +177,7 @@ impl AuthModeWidget {
};
let msg = format!(
" Youre 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<Line> = 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,8 +402,14 @@ 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;
if let LoginStatus::Auth(auth) = &self.login_status
&& 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;
}
@@ -348,14 +442,16 @@ 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 +479,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 +501,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);
}

View File

@@ -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;
@@ -73,6 +74,8 @@ impl OnboardingScreen {
let mut steps: Vec<Step> = 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 +85,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 +122,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;