Group alternative sign-in methods in a submenu (#58427)

## What changed

Move OpenAI API key, Amazon Bedrock, and Google Cloud Vertex AI options under “Sign in another way” during onboarding. Keep ChatGPT and device code sign-in on the main menu, and let `Esc` return from the alternative methods menu.

Guard activation keys across menu transitions so holding `Enter` or a numeric shortcut cannot advance through multiple steps or save a prefilled API key without a fresh confirmation.

## Testing

Add regression coverage for held activation keys and update onboarding tests and snapshots for submenu navigation, provider availability, and returning with `Esc`.

GitOrigin-RevId: b5aa56e48f10b490cc8f04b5d4288d21b76d2c61
This commit is contained in:
alexsong-oai
2026-10-30 20:26:22 +00:00
committed by copyberry
parent 8801b9a8a6
commit 22b3365156
9 changed files with 218 additions and 100 deletions
+5 -2
View File
@@ -182,6 +182,7 @@ async fn daemon_startup(command: &str) -> Result<()> {
.success(),
"failed to prepare CLI test executable"
);
let bedrock_onboarding = matches!(command, "bedrock" | "bedrock-running");
fs::write(
home.path().join("config.toml"),
format!(
@@ -195,6 +196,8 @@ async fn daemon_startup(command: &str) -> Result<()> {
[projects.{}]\ntrust_level = \"{}\"\n",
if command == "discovered" {
"tui.status_line=[\"model\",\"thread-id\",\"thread-name\"]\nfeatures.daemon_auto_start=false"
} else if bedrock_onboarding {
"features.gcp_setup_wizard=false"
} else {
""
},
@@ -210,7 +213,6 @@ async fn daemon_startup(command: &str) -> Result<()> {
let persisted = command == "mismatch-disable-persisted";
let disabling = matches!(command, "mismatch-disable" | "mismatch-disable-persisted");
let restart = command == "mismatch-restart" || disabling;
let bedrock_onboarding = matches!(command, "bedrock" | "bedrock-running");
if !bedrock_onboarding {
fs::write(
home.path().join("auth.json"),
@@ -361,7 +363,8 @@ async fn daemon_startup(command: &str) -> Result<()> {
if restart { "Server:Localbackgroundserver" } else { "Model:" }
}
} else if bedrock_onboarding {
"UseAmazonBedrock"
// With GCP disabled, only Bedrock makes the sign-in menu advertise another provider.
"UseanAPIkeyoranotherprovider"
} else {
args.extend([command.into(), "--strict-config".into()]);
if command == "resume" {
+110 -57
View File
@@ -43,9 +43,11 @@ use codex_protocol::config_types::ForcedLoginMethod;
use std::cell::Cell;
use std::sync::Arc;
use std::sync::RwLock;
use std::time::Instant;
use uuid::Uuid;
use crate::LoginStatus;
use crate::activation_key_guard::ActivationKeyGuard;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::motion::MotionMode;
@@ -95,11 +97,18 @@ pub(crate) enum SignInState {
pub(crate) enum SignInOption {
ChatGpt,
DeviceCode,
OtherMethods,
ApiKey,
Bedrock,
Gcp,
}
impl SignInOption {
fn is_alternative(self) -> bool {
!matches!(self, Self::ChatGpt | Self::DeviceCode | Self::OtherMethods)
}
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
pub(super) fn onboarding_request_id() -> codex_app_server_protocol::RequestId {
codex_app_server_protocol::RequestId::String(Uuid::new_v4().to_string())
@@ -181,6 +190,14 @@ impl ContinueWithDeviceCodeState {
impl KeyboardHandler for AuthModeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if self
.activation_key_guard
.as_mut()
.is_some_and(|guard| guard.consume(KeyBinding::from_event(key_event), key_event.kind))
|| key_event.kind == KeyEventKind::Release
{
return;
}
if self.handle_gcp_key_event(&key_event) || self.handle_bedrock_key_event(&key_event) {
return;
}
@@ -196,31 +213,26 @@ impl KeyboardHandler for AuthModeWidget {
self.move_highlight(/*delta*/ 1);
return;
}
if key_event.kind != KeyEventKind::Press {
return;
}
if keys::SELECT_FIRST.is_pressed(key_event) {
self.select_option_by_index(/*index*/ 0);
self.select_option_by_index(/*index*/ 0, key_event);
return;
}
if keys::SELECT_SECOND.is_pressed(key_event) {
self.select_option_by_index(/*index*/ 1);
self.select_option_by_index(/*index*/ 1, key_event);
return;
}
if keys::SELECT_THIRD.is_pressed(key_event) {
self.select_option_by_index(/*index*/ 2);
return;
}
if keys::SELECT_FOURTH.is_pressed(key_event) {
self.select_option_by_index(/*index*/ 3);
return;
}
if keys::SELECT_FIFTH.is_pressed(key_event) {
self.select_option_by_index(/*index*/ 4);
self.select_option_by_index(/*index*/ 2, key_event);
return;
}
if keys::CONFIRM.is_pressed(key_event) {
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
match sign_in_state {
SignInState::PickMode => {
self.handle_sign_in_option(self.highlighted_mode);
self.activate_highlighted_mode(key_event);
}
SignInState::ChatGptSuccessMessage => {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
@@ -230,8 +242,14 @@ impl KeyboardHandler for AuthModeWidget {
return;
}
if keys::CANCEL.is_pressed(key_event) {
tracing::info!("Cancel onboarding auth step");
self.cancel_active_attempt();
if matches!(*self.sign_in_state.read().unwrap(), SignInState::PickMode)
&& self.highlighted_mode.is_alternative()
{
self.highlighted_mode = SignInOption::OtherMethods;
self.set_error(/*message*/ None);
} else {
self.cancel_active_attempt();
}
}
}
@@ -261,6 +279,7 @@ impl KeyboardHandler for AuthModeWidget {
pub(crate) struct AuthModeWidget {
pub request_frame: FrameRequester,
pub highlighted_mode: SignInOption,
pub(super) activation_key_guard: Option<ActivationKeyGuard>,
pub error: Arc<RwLock<Option<String>>>,
pub sign_in_state: Arc<RwLock<SignInState>>,
pub login_status: LoginStatus,
@@ -361,42 +380,34 @@ impl AuthModeWidget {
}
fn displayed_sign_in_options(&self) -> Vec<SignInOption> {
if self.highlighted_mode.is_alternative() {
let mut options = vec![SignInOption::ApiKey];
if self.bedrock_setup_enabled {
options.push(SignInOption::Bedrock);
}
if self.gcp_setup_enabled {
options.push(SignInOption::Gcp);
}
return options;
}
let mut options = vec![SignInOption::ChatGpt];
if self.is_chatgpt_login_allowed() {
options.push(SignInOption::DeviceCode);
}
if self.is_api_login_allowed() {
options.push(SignInOption::ApiKey);
if self.bedrock_setup_enabled {
options.push(SignInOption::Bedrock);
}
if self.gcp_setup_enabled {
options.push(SignInOption::Gcp);
}
}
options
}
fn selectable_sign_in_options(&self) -> Vec<SignInOption> {
let mut options = Vec::new();
if self.is_chatgpt_login_allowed() {
options.push(SignInOption::ChatGpt);
options.push(SignInOption::DeviceCode);
}
if self.is_api_login_allowed() {
options.push(SignInOption::ApiKey);
if self.bedrock_setup_enabled {
options.push(SignInOption::Bedrock);
}
if self.gcp_setup_enabled {
options.push(SignInOption::Gcp);
}
options.push(SignInOption::OtherMethods);
}
options
}
fn move_highlight(&mut self, delta: isize) {
let options = self.selectable_sign_in_options();
if !matches!(*self.sign_in_state.read().unwrap(), SignInState::PickMode) {
return;
}
let mut options = self.displayed_sign_in_options();
if !self.is_chatgpt_login_allowed() {
options.retain(|option| *option != SignInOption::ChatGpt);
}
if options.is_empty() {
return;
}
@@ -410,18 +421,38 @@ impl AuthModeWidget {
self.highlighted_mode = options[next_index];
}
fn select_option_by_index(&mut self, index: usize) {
fn select_option_by_index(&mut self, index: usize, key_event: KeyEvent) {
if !matches!(*self.sign_in_state.read().unwrap(), SignInState::PickMode) {
return;
}
let options = self.displayed_sign_in_options();
if let Some(option) = options.get(index).copied() {
self.handle_sign_in_option(option);
if let Some(option) = options.get(index).copied()
&& (option != SignInOption::ChatGpt || self.is_chatgpt_login_allowed())
{
self.highlighted_mode = option;
self.activate_highlighted_mode(key_event);
}
}
fn activate_highlighted_mode(&mut self, key_event: KeyEvent) {
let option = self.highlighted_mode;
if option == SignInOption::OtherMethods || option.is_alternative() {
// Enter and printable shortcuts can repeat as plain presses without a release.
self.activation_key_guard = Some(ActivationKeyGuard::new_at(
KeyBinding::from_event(key_event),
Instant::now(),
/*key_releases_reported*/ false,
));
}
self.handle_sign_in_option(option);
}
fn handle_sign_in_option(&mut self, option: SignInOption) {
match option {
SignInOption::OtherMethods => {
self.highlighted_mode = SignInOption::ApiKey;
self.set_error(/*message*/ None);
}
SignInOption::ChatGpt => {
if self.is_chatgpt_login_allowed() {
self.start_chatgpt_login();
@@ -466,7 +497,9 @@ impl AuthModeWidget {
}
fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = if self.bedrock_setup_enabled || self.gcp_setup_enabled {
let mut lines: Vec<Line> = if self.highlighted_mode.is_alternative() {
vec![" Sign in another way".into(), "".into()]
} else if self.bedrock_setup_enabled || self.gcp_setup_enabled {
vec![" Choose how you want to use Codex.".into(), "".into()]
} else {
vec![
@@ -523,6 +556,18 @@ impl AuthModeWidget {
for (idx, option) in self.displayed_sign_in_options().into_iter().enumerate() {
match option {
SignInOption::OtherMethods => {
lines.extend(create_mode_item(
idx,
option,
"Sign in another way",
if self.bedrock_setup_enabled || self.gcp_setup_enabled {
"Use an API key or another provider"
} else {
"Use an OpenAI API key"
},
));
}
SignInOption::ChatGpt => {
lines.extend(create_mode_item(
idx,
@@ -581,6 +626,13 @@ impl AuthModeWidget {
self.confirm_binding().into(),
" to continue".dim(),
]));
if self.highlighted_mode.is_alternative() {
lines.push(Line::from(vec![
" Press ".dim(),
self.cancel_binding().into(),
" to go back".dim(),
]));
}
if let Some(err) = self.error_message() {
lines.push("".into());
lines.push(err.red().into());
@@ -813,7 +865,9 @@ impl AuthModeWidget {
*guard = SignInState::PickMode;
self.set_error(/*message*/ None);
should_request_frame = true;
} else if keys::CONFIRM.is_pressed(*key_event) {
} else if key_event.kind == KeyEventKind::Press
&& keys::CONFIRM.is_pressed(*key_event)
{
let trimmed = state.value.trim().to_string();
if trimmed.is_empty() {
self.set_error(Some("API key cannot be empty".to_string()));
@@ -1321,6 +1375,7 @@ mod tests {
let widget = AuthModeWidget {
request_frame: FrameRequester::test_dummy(),
highlighted_mode: SignInOption::ChatGpt,
activation_key_guard: None,
error: Arc::new(RwLock::new(None)),
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
login_status: LoginStatus::NotAuthenticated,
@@ -1383,7 +1438,7 @@ mod tests {
vec![
SignInOption::ChatGpt,
SignInOption::DeviceCode,
SignInOption::ApiKey,
SignInOption::OtherMethods,
]
);
@@ -1404,15 +1459,13 @@ mod tests {
&widget.app_server_target,
Feature::BedrockSetupWizard,
);
widget.handle_key_event(KeyCode::Char('3').into());
assert_eq!(
widget.displayed_sign_in_options(),
vec![
SignInOption::ChatGpt,
SignInOption::DeviceCode,
SignInOption::ApiKey,
SignInOption::Bedrock,
]
vec![SignInOption::ApiKey, SignInOption::Bedrock]
);
widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
widget.move_highlight(/*delta*/ 1);
let area = Rect::new(0, 0, 76, 19);
let mut buffer = Buffer::empty(area);
@@ -1438,11 +1491,8 @@ mod tests {
2. Sign in with Device Code
Sign in from another device with a one-time code
3. Use an OpenAI API key
Pay for what you use
4. Use Amazon Bedrock
Connect using your AWS credentials
3. Sign in another way
Use an API key or another provider
Press enter to continue
"###);
@@ -1753,6 +1803,9 @@ mod tests {
}
}
#[cfg(test)]
mod activation_key_tests;
#[cfg(test)]
#[path = "gcp_tests.rs"]
mod gcp_tests;
@@ -0,0 +1,55 @@
#![cfg(test)]
//! Regression coverage for a key held across the sign-in menus.
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn held_enter_requires_a_fresh_confirmation_to_save_a_prefilled_api_key() {
let (mut widget, _home) = tests::widget_forced_chatgpt().await;
widget.auth_config.forced_login_method = None;
let enter = |kind| KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, kind);
widget.handle_key_event(KeyCode::Up.into());
widget.handle_key_event(enter(KeyEventKind::Press));
widget.handle_key_event(enter(KeyEventKind::Repeat));
widget.handle_key_event(enter(KeyEventKind::Press));
widget.handle_key_event(KeyCode::Down.into());
widget.handle_key_event(enter(KeyEventKind::Repeat));
assert_eq!(widget.highlighted_mode, SignInOption::ApiKey);
assert!(matches!(
*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
widget.handle_key_event(enter(KeyEventKind::Release));
widget.handle_key_event(enter(KeyEventKind::Press));
{
let mut state = widget.sign_in_state.write().unwrap();
let SignInState::ApiKeyEntry(input) = &mut *state else {
panic!("expected API key entry");
};
input.value = "test-api-key".into();
input.prepopulated_from_env = true;
}
let (request_frame, mut frames) = FrameRequester::test_channel();
widget.request_frame = request_frame;
widget.handle_key_event(enter(KeyEventKind::Press));
widget.handle_key_event(enter(KeyEventKind::Repeat));
widget.handle_key_event(KeyCode::Left.into());
widget.handle_key_event(enter(KeyEventKind::Repeat));
assert_eq!(
frames.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
);
widget.handle_key_event(enter(KeyEventKind::Release));
widget.handle_key_event(enter(KeyEventKind::Press));
frames.try_recv().expect("fresh Enter should start saving");
frames.recv().await.expect("API key save should finish");
assert!(matches!(
*widget.sign_in_state.read().unwrap(),
SignInState::ApiKeyConfigured
));
}
+31 -20
View File
@@ -39,30 +39,29 @@ async fn gcp_menu_gating_and_setup_flow() {
Rect::new(0, 0, 76, 8),
);
let press = |key| KeyEvent::new(key, KeyModifiers::NONE);
widget.gcp_setup_enabled = true;
widget.handle_key_event(press(KeyCode::Char('3')));
assert_eq!(
widget.displayed_sign_in_options(),
vec![
SignInOption::ChatGpt,
SignInOption::DeviceCode,
SignInOption::ApiKey,
SignInOption::Gcp
]
vec![SignInOption::ApiKey, SignInOption::Gcp]
);
widget.bedrock_setup_enabled = true;
assert_eq!(
widget.selectable_sign_in_options(),
vec![
SignInOption::ChatGpt,
SignInOption::DeviceCode,
SignInOption::ApiKey,
SignInOption::Bedrock,
SignInOption::Gcp
]
);
widget.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Char('3'),
KeyModifiers::NONE,
KeyEventKind::Repeat,
));
assert_eq!(widget.highlighted_mode, SignInOption::ApiKey);
assert!(matches!(
*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
snapshot(&widget, "gcp_sign_in_menu", Rect::new(0, 0, 76, 21));
widget.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
snapshot(&widget, "gcp_sign_in_menu_short", Rect::new(0, 0, 76, 14));
widget.handle_key_event(press(KeyCode::Up));
snapshot(&widget, "gcp_sign_in_menu_short", Rect::new(0, 0, 76, 8));
widget.handle_key_event(press(KeyCode::Esc));
assert_eq!(widget.highlighted_mode, SignInOption::OtherMethods);
let area = Rect::new(0, 0, 76, 12);
widget.auth_config.forced_login_method = Some(ForcedLoginMethod::Chatgpt);
@@ -79,16 +78,28 @@ async fn gcp_menu_gating_and_setup_flow() {
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::pending("login".into()));
widget.handle_key_event(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE));
widget.handle_key_event(press(KeyCode::Char('3')));
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::ChatGptDeviceCode(_)
));
widget.cancel_active_attempt();
widget.handle_key_event(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE));
widget.handle_key_event(press(KeyCode::Char('3')));
widget.handle_key_event(press(KeyCode::Char('1')));
widget.handle_key_event(press(KeyCode::Esc));
assert_eq!(widget.highlighted_mode, SignInOption::ApiKey);
widget.handle_key_event(press(KeyCode::Char('3')));
widget.handle_key_event(press(KeyCode::Esc));
assert_eq!(widget.highlighted_mode, SignInOption::Gcp);
widget.handle_key_event(press(KeyCode::Enter));
assert!(widget.is_text_entry_active());
assert!(widget.should_suppress_printable_quit());
widget.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Enter,
KeyModifiers::NONE,
KeyEventKind::Release,
));
widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
widget.error_message().as_deref(),
-2
View File
@@ -23,8 +23,6 @@ pub(crate) const SELECT_SECOND: [KeyBinding; 2] = [
key_hint::plain(KeyCode::Char('n')),
];
pub(crate) const SELECT_THIRD: [KeyBinding; 1] = [key_hint::plain(KeyCode::Char('3'))];
pub(crate) const SELECT_FOURTH: [KeyBinding; 1] = [key_hint::plain(KeyCode::Char('4'))];
pub(crate) const SELECT_FIFTH: [KeyBinding; 1] = [key_hint::plain(KeyCode::Char('5'))];
pub(crate) const CONFIRM: [KeyBinding; 1] = [key_hint::plain(KeyCode::Enter)];
pub(crate) const CANCEL: [KeyBinding; 1] = [key_hint::plain(KeyCode::Esc)];
pub(crate) const COPY_LINK: [KeyBinding; 1] = [key_hint::plain(KeyCode::Char('c'))];
@@ -173,12 +173,13 @@ impl OnboardingScreen {
if auth_config.is_login_method_allowed(ForcedLoginMethod::Chatgpt) {
SignInOption::ChatGpt
} else {
SignInOption::ApiKey
SignInOption::OtherMethods
};
if let Some(app_server_request_handle) = app_server_request_handle {
steps.push(Step::Auth(AuthModeWidget {
request_frame: tui.frame_requester(),
highlighted_mode,
activation_key_guard: None,
error: Arc::new(RwLock::new(None)),
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
login_status,
@@ -352,7 +353,10 @@ impl KeyboardHandler for OnboardingScreen {
/// starts, while cloud provider fields suppress them from the first character.
/// Control/alt quit chords still work as emergency exits.
fn handle_key_event(&mut self, key_event: KeyEvent) {
if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
if key_event.kind == KeyEventKind::Release {
if let Some(widget) = self.auth_widget_mut() {
widget.handle_key_event(key_event);
}
return;
}
let text_entry_context = self.text_entry_context();
@@ -2,21 +2,16 @@
source: tui/src/onboarding/gcp_tests.rs
expression: terminal.backend().to_string()
---
Choose how you want to use Codex.
Sign in another way
> 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. Use an OpenAI API key
> 1. Use an OpenAI API key
Pay for what you use
4. Use Amazon Bedrock
2. Use Amazon Bedrock
Connect using your AWS credentials
5. Use Google Cloud Vertex AI
3. Use Google Cloud Vertex AI
Connect using your Google Cloud credentials
Press enter to continue
Press esc to go back
@@ -2,11 +2,10 @@
source: tui/src/onboarding/gcp_tests.rs
expression: terminal.backend().to_string()
---
Choose how you want to use Codex.
Sign in another way
1. Sign in with ChatGPT
2. Sign in with Device Code
3. Use an OpenAI API key
4. Use Amazon Bedrock
> 5. Use Google Cloud Vertex AI
1. Use an OpenAI API key
2. Use Amazon Bedrock
> 3. Use Google Cloud Vertex AI
Press enter to continue
Press esc to go back
@@ -7,5 +7,5 @@ expression: terminal.backend().to_string()
> 1. Sign in with ChatGPT
2. Sign in with Device Code
3. Use an OpenAI API key
3. Sign in another way
Press enter to continue