Add Google Cloud Vertex AI setup to TUI onboarding (#57857)

## What changed

- Add a sign-in wizard behind `features.gcp_setup_wizard`, disabled by default, to collect a project ID, optional location, and optional credential file. Users can leave the credential file blank to use Application Default Credentials.
- Delegate credential validation and configuration persistence to the app-server, showing progress and allowing retries after errors.
- Share editable single-line fields with Bedrock setup and compact the sign-in menu when terminal height is limited.

## Testing

Add onboarding tests and snapshots covering feature gating, field editing, navigation, validation failures, successful configuration persistence, and compact menu rendering.

GitOrigin-RevId: 96eb31e847aa1c2d503176be68ff2e2efe4722fa
This commit is contained in:
alexsong-oai
2026-10-28 23:06:34 +00:00
committed by copyberry
parent f5f1d59856
commit f91edce53b
25 changed files with 948 additions and 208 deletions
+6
View File
@@ -1044,6 +1044,9 @@
"fast_mode": {
"type": "boolean"
},
"gcp_setup_wizard": {
"type": "boolean"
},
"goals": {
"type": "boolean"
},
@@ -7693,6 +7696,9 @@
"fast_mode": {
"type": "boolean"
},
"gcp_setup_wizard": {
"type": "boolean"
},
"goals": {
"type": "boolean"
},
+8
View File
@@ -445,6 +445,8 @@ pub enum Feature {
AuthElicitation,
/// Offer Amazon Bedrock setup during TUI sign-in onboarding.
BedrockSetupWizard,
/// Offer Google Cloud Vertex AI setup during TUI sign-in onboarding.
GcpSetupWizard,
/// Removed compatibility flag retained as a no-op.
Personality,
/// Enable native artifact tools.
@@ -2123,6 +2125,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::GcpSetupWizard,
key: "gcp_setup_wizard",
stage: Stage::Stable,
default_enabled: false,
},
FeatureSpec {
id: Feature::Personality,
key: "personality",
+2 -1
View File
@@ -771,9 +771,10 @@ impl TextArea {
return;
}
self.insert_str(&c.to_string());
} else {
tracing::debug!(kind = ?event.kind, modifiers = ?event.modifiers, "Unhandled key event in TextArea");
}
tracing::debug!("Unhandled key event in TextArea: {:?}", event);
self.mouse_selection = None;
}
@@ -18,7 +18,7 @@ impl TextArea {
}
}
pub(super) fn single_line_viewport(&self, width: u16) -> (usize, u16) {
pub(crate) fn single_line_viewport(&self, width: u16) -> (usize, u16) {
let cursor_width = self.text[self.cursor_pos..]
.graphemes(/*is_extended*/ true)
.next()
+13 -3
View File
@@ -1366,16 +1366,25 @@ async fn run_ratatui_app(
// Authentication can change while any interactive onboarding screen is open.
startup_account = None;
let show_login_screen = should_show_login_screen(login_status, requires_openai_auth);
let bedrock_setup_enabled = should_show_bedrock_setup_wizard(
let bedrock_setup_enabled = should_show_provider_setup_wizard(
login_status,
requires_openai_auth,
&initial_config,
&app_server_target,
Feature::BedrockSetupWizard,
);
let gcp_setup_enabled = should_show_provider_setup_wizard(
login_status,
requires_openai_auth,
&initial_config,
&app_server_target,
Feature::GcpSetupWizard,
);
let onboarding_result = run_onboarding_app(
OnboardingScreenArgs {
show_login_screen,
bedrock_setup_enabled,
gcp_setup_enabled,
show_trust_screen: should_show_trust_screen_flag,
remote_project_trust: None,
login_status,
@@ -2450,15 +2459,16 @@ fn should_show_login_screen(login_status: LoginStatus, requires_openai_auth: boo
login_status == LoginStatus::NotAuthenticated
}
fn should_show_bedrock_setup_wizard(
fn should_show_provider_setup_wizard(
login_status: LoginStatus,
requires_openai_auth: bool,
config: &Config,
app_server_target: &AppServerTarget,
feature: Feature,
) -> bool {
matches!(app_server_target, AppServerTarget::Embedded)
&& should_show_login_screen(login_status, requires_openai_auth)
&& config.features.enabled(Feature::BedrockSetupWizard)
&& config.features.enabled(feature)
&& config.model_provider_id == "openai"
&& config
.auth_config()
+53 -26
View File
@@ -76,44 +76,58 @@ requires_openai_auth = {requires_openai_auth}
}
#[tokio::test]
async fn bedrock_setup_wizard_requires_eligible_onboarding() -> color_eyre::Result<()> {
async fn provider_setup_wizards_require_eligible_onboarding() -> color_eyre::Result<()> {
let shared_endpoint = RemoteAppServerEndpoint::WebSocket {
websocket_url: "ws://127.0.0.1:4500/".to_string(),
auth_token: None,
};
let enabled = "[features]\nbedrock_setup_wizard = true\n";
let enabled = "[features]\nbedrock_setup_wizard = true\ngcp_setup_wizard = true\n";
for (label, config_toml, login_status, target, expected) in [
(
"enabled by default",
"provider defaults",
"",
LoginStatus::NotAuthenticated,
AppServerTarget::Embedded,
true,
[true, false],
),
(
"explicitly disabled",
"[features]\nbedrock_setup_wizard = false\n",
"[features]\nbedrock_setup_wizard = false\ngcp_setup_wizard = false\n",
LoginStatus::NotAuthenticated,
AppServerTarget::Embedded,
false,
[false, false],
),
(
"only GCP enabled",
"[features]\nbedrock_setup_wizard = false\ngcp_setup_wizard = true\n",
LoginStatus::NotAuthenticated,
AppServerTarget::Embedded,
[false, true],
),
(
"enabled for the default provider",
enabled,
LoginStatus::NotAuthenticated,
AppServerTarget::Embedded,
true,
[true, true],
),
(
"explicit OpenAI provider",
"model_provider = \"openai\"\n[features]\nbedrock_setup_wizard = true\n",
"explicit OpenAI provider with default flags",
"model_provider = \"openai\"\n",
LoginStatus::NotAuthenticated,
AppServerTarget::Embedded,
true,
[true, false],
),
(
"custom provider",
"explicit OpenAI provider with both wizards enabled",
"model_provider = \"openai\"\n[features]\nbedrock_setup_wizard = true\ngcp_setup_wizard = true\n",
LoginStatus::NotAuthenticated,
AppServerTarget::Embedded,
[true, true],
),
(
"custom provider requiring OpenAI auth",
r#"
model_provider = "test-provider"
[model_providers.test-provider]
@@ -123,24 +137,25 @@ wire_api = "responses"
requires_openai_auth = true
[features]
bedrock_setup_wizard = true
gcp_setup_wizard = true
"#,
LoginStatus::NotAuthenticated,
AppServerTarget::Embedded,
false,
[false, false],
),
(
"forced ChatGPT login",
"forced_login_method = \"chatgpt\"\n[features]\nbedrock_setup_wizard = true\n",
"forced_login_method = \"chatgpt\"\n[features]\nbedrock_setup_wizard = true\ngcp_setup_wizard = true\n",
LoginStatus::NotAuthenticated,
AppServerTarget::Embedded,
false,
[false, false],
),
(
"existing authentication",
enabled,
LoginStatus::AuthMode(AuthMode::Chatgpt),
AppServerTarget::Embedded,
false,
[false, false],
),
(
"shared local daemon",
@@ -150,7 +165,7 @@ bedrock_setup_wizard = true
allow_embedded_fallback: true,
endpoint: shared_endpoint.clone(),
},
false,
[false, false],
),
(
"remote app server",
@@ -159,23 +174,35 @@ bedrock_setup_wizard = true
AppServerTarget::Remote {
endpoint: shared_endpoint,
},
false,
[false, false],
),
] {
let codex_home = TempDir::new()?;
std::fs::write(codex_home.path().join("config.toml"), config_toml)?;
let config = build_config(&codex_home).await?;
assert_eq!(
should_show_bedrock_setup_wizard(
for (feature, expected) in [Feature::BedrockSetupWizard, Feature::GcpSetupWizard]
.into_iter()
.zip(expected)
{
assert_eq!(
should_show_provider_setup_wizard(
login_status,
/*requires_openai_auth*/ true,
&config,
&target,
feature,
),
expected,
"{label}: {feature:?}"
);
assert!(!should_show_provider_setup_wizard(
login_status,
/*requires_openai_auth*/ true,
/*requires_openai_auth*/ false,
&config,
&target
),
expected,
"{label}"
);
&target,
feature,
));
}
}
Ok(())
+91 -24
View File
@@ -51,6 +51,7 @@ use crate::key_hint::KeyBindingListExt;
use crate::motion::MotionMode;
use crate::motion::shimmer_text;
use crate::onboarding::bedrock::BedrockState;
use crate::onboarding::gcp::GcpState;
use crate::onboarding::keys;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
@@ -91,6 +92,8 @@ pub(crate) enum SignInState {
ApiKeyConfigured,
Bedrock(BedrockState),
BedrockConfigured,
Gcp(GcpState),
GcpConfigured,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -99,6 +102,7 @@ pub(crate) enum SignInOption {
DeviceCode,
ApiKey,
Bedrock,
Gcp,
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
@@ -182,7 +186,7 @@ impl ContinueWithDeviceCodeState {
impl KeyboardHandler for AuthModeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.handle_bedrock_key_event(&key_event) {
if self.handle_gcp_key_event(&key_event) || self.handle_bedrock_key_event(&key_event) {
return;
}
if self.handle_api_key_entry_key_event(&key_event) {
@@ -213,6 +217,10 @@ impl KeyboardHandler for AuthModeWidget {
self.select_option_by_index(/*index*/ 3);
return;
}
if keys::SELECT_FIFTH.is_pressed(key_event) {
self.select_option_by_index(/*index*/ 4);
return;
}
if keys::CONFIRM.is_pressed(key_event) {
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
match sign_in_state {
@@ -233,17 +241,22 @@ impl KeyboardHandler for AuthModeWidget {
}
fn handle_paste(&mut self, pasted: String) {
let sign_in_state = self.sign_in_state.read().unwrap();
match &*sign_in_state {
SignInState::Bedrock(_) => {
drop(sign_in_state);
let _ = self.handle_bedrock_paste(&pasted);
}
let mut state = self.sign_in_state.write().unwrap();
let input = match &mut *state {
SignInState::Bedrock(state) => state.active_input_mut(),
SignInState::Gcp(state) => state.active_input_mut(),
SignInState::ApiKeyEntry(_) => {
drop(sign_in_state);
drop(state);
let _ = self.handle_api_key_entry_paste(pasted);
return;
}
_ => {}
_ => None,
};
if let Some(input) = input {
input.paste(&pasted);
self.set_error(/*message*/ None);
drop(state);
self.request_frame.schedule_frame();
}
}
}
@@ -260,6 +273,7 @@ pub(crate) struct AuthModeWidget {
pub app_server_request_handle: AppServerRequestHandle,
pub auth_config: AuthConfig,
pub bedrock_setup_enabled: bool,
pub gcp_setup_enabled: bool,
pub animations_enabled: bool,
pub animations_suppressed: Cell<bool>,
}
@@ -315,6 +329,7 @@ impl AuthModeWidget {
self.sign_in_state.read().is_ok_and(|guard| match &*guard {
SignInState::ApiKeyEntry(_) => true,
SignInState::Bedrock(state) => state.is_text_entry_active(),
SignInState::Gcp(state) => state.is_text_entry_active(),
_ => false,
})
}
@@ -322,11 +337,12 @@ impl AuthModeWidget {
/// Returns whether printable quit shortcuts must be treated as text input.
///
/// OpenAI API-key entry keeps its existing empty-field quit behavior, while
/// Bedrock fields accept printable input from their first character.
/// Cloud setup fields accept printable input from their first character.
pub(crate) fn should_suppress_printable_quit(&self) -> bool {
self.sign_in_state.read().is_ok_and(|guard| match &*guard {
SignInState::ApiKeyEntry(state) => !state.value.is_empty(),
SignInState::Bedrock(state) => state.is_text_entry_active(),
SignInState::Gcp(state) => state.is_text_entry_active(),
_ => false,
})
}
@@ -359,6 +375,9 @@ impl AuthModeWidget {
if self.bedrock_setup_enabled {
options.push(SignInOption::Bedrock);
}
if self.gcp_setup_enabled {
options.push(SignInOption::Gcp);
}
}
options
}
@@ -374,6 +393,9 @@ impl AuthModeWidget {
if self.bedrock_setup_enabled {
options.push(SignInOption::Bedrock);
}
if self.gcp_setup_enabled {
options.push(SignInOption::Gcp);
}
}
options
}
@@ -394,6 +416,9 @@ impl AuthModeWidget {
}
fn select_option_by_index(&mut self, index: usize) {
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);
@@ -426,6 +451,15 @@ impl AuthModeWidget {
self.disallow_api_login();
}
}
SignInOption::Gcp => {
if self.gcp_setup_enabled && self.is_api_login_allowed() {
*self.sign_in_state.write().unwrap() = SignInState::Gcp(GcpState::default());
self.set_error(/*message*/ None);
self.request_frame.schedule_frame();
} else if !self.is_api_login_allowed() {
self.disallow_api_login();
}
}
}
}
@@ -437,7 +471,7 @@ impl AuthModeWidget {
}
fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = if self.bedrock_setup_enabled {
let mut lines: Vec<Line> = if self.bedrock_setup_enabled || self.gcp_setup_enabled {
vec![" Choose how you want to use Codex.".into(), "".into()]
} else {
vec![
@@ -453,10 +487,12 @@ impl AuthModeWidget {
]
};
let create_mode_item = |idx: usize,
selected_mode: SignInOption,
text: &str,
description: &str|
let options_start = lines.len();
let mut compact_options = Vec::new();
let mut create_mode_item = |idx: usize,
selected_mode: SignInOption,
text: &str,
description: &str|
-> Vec<Line<'static>> {
let is_selected = self.highlighted_mode == selected_mode;
let caret = if is_selected { ">" } else { " " };
@@ -479,6 +515,7 @@ impl AuthModeWidget {
.style(Style::default().add_modifier(Modifier::DIM))
};
compact_options.push(line1.clone());
vec![line1, line2]
};
@@ -511,11 +548,7 @@ impl AuthModeWidget {
lines.extend(create_mode_item(
idx,
option,
if self.bedrock_setup_enabled {
"Use an OpenAI API key"
} else {
"Provide your own API key"
},
"Use an OpenAI API key",
"Pay for what you use",
));
}
@@ -527,10 +560,19 @@ impl AuthModeWidget {
"Connect using your AWS credentials",
));
}
SignInOption::Gcp => {
lines.extend(create_mode_item(
idx,
option,
"Use Google Cloud Vertex AI",
"Connect using your Google Cloud credentials",
));
}
}
lines.push("".into());
}
let options_end = lines.len();
if !self.is_api_login_allowed() {
lines.push(
" API key login is disabled by this workspace. Sign in with ChatGPT to continue."
@@ -549,6 +591,14 @@ impl AuthModeWidget {
lines.push(err.red().into());
}
// Keep the options and confirmation hint visible in short terminals.
if Paragraph::new(lines.clone())
.wrap(Wrap { trim: false })
.line_count(area.width)
> usize::from(area.height)
{
lines.splice(options_start..options_end, compact_options);
}
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
@@ -1050,10 +1100,12 @@ impl StepStateProvider for AuthModeWidget {
| SignInState::ChatGptContinueInBrowser(_)
| SignInState::ChatGptDeviceCode(_)
| SignInState::ChatGptSuccessMessage
| SignInState::Bedrock(_) => StepState::InProgress,
| SignInState::Bedrock(_)
| SignInState::Gcp(_) => StepState::InProgress,
SignInState::ChatGptSuccess
| SignInState::ApiKeyConfigured
| SignInState::BedrockConfigured => StepState::Complete,
| SignInState::BedrockConfigured
| SignInState::GcpConfigured => StepState::Complete,
}
}
}
@@ -1086,6 +1138,14 @@ impl WidgetRef for AuthModeWidget {
SignInState::Bedrock(state) => {
state.render(area, buf, self.error_message());
}
SignInState::Gcp(state) => {
state.render(area, buf, self.error_message());
}
SignInState::GcpConfigured => {
Paragraph::new("✓ Google Cloud Vertex AI configured".green())
.wrap(Wrap { trim: false })
.render(area, buf);
}
SignInState::BedrockConfigured => {
Paragraph::new("✓ Amazon Bedrock configured".green())
.wrap(Wrap { trim: false })
@@ -1127,6 +1187,7 @@ mod tests {
use codex_app_server_client::RemoteAppServerEndpoint;
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_config::chatgpt_config_bundle_loader_for_storage;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::sync::Arc;
@@ -1219,7 +1280,7 @@ mod tests {
));
}
async fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) {
pub(super) async fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) {
let codex_home = TempDir::new().unwrap();
let codex_home_path = codex_home.path().to_path_buf();
let config = ConfigBuilder::default()
@@ -1271,6 +1332,7 @@ mod tests {
app_server_request_handle: AppServerRequestHandle::InProcess(client.request_handle()),
auth_config,
bedrock_setup_enabled: false,
gcp_setup_enabled: false,
animations_enabled: true,
animations_suppressed: std::cell::Cell::new(false),
};
@@ -1339,11 +1401,12 @@ mod tests {
.build()
.await
.unwrap();
widget.bedrock_setup_enabled = crate::should_show_bedrock_setup_wizard(
widget.bedrock_setup_enabled = crate::should_show_provider_setup_wizard(
widget.login_status,
/*requires_openai_auth*/ true,
&config,
&widget.app_server_target,
Feature::BedrockSetupWizard,
);
assert_eq!(
widget.displayed_sign_in_options(),
@@ -1693,3 +1756,7 @@ mod tests {
);
}
}
#[cfg(test)]
#[path = "gcp_tests.rs"]
mod gcp_tests;
+64 -133
View File
@@ -4,6 +4,8 @@ use super::auth::AuthModeWidget;
use super::auth::SignInState;
use super::auth::onboarding_request_id;
use super::keys;
use super::provider_setup::FieldDisplay;
use super::provider_setup::ProviderInput;
use crate::key_hint::KeyBindingListExt;
use crate::wrapping::word_wrap_lines;
use codex_app_server_client::AppServerRequestHandle;
@@ -23,7 +25,6 @@ use codex_app_server_protocol::RequestId;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
@@ -50,15 +51,15 @@ pub(super) struct BedrockState {
enum BedrockView {
Discovering(RequestId),
Methods(BedrockMethodList),
ProfileEntry(String),
ProfileEntry(ProviderInput),
AccessKeyEntry {
values: [String; 3],
values: [ProviderInput; 3],
selected_field: usize,
},
ApiKeyEntry(String),
ApiKeyEntry(ProviderInput),
RegionEntry {
credential: BedrockCredential,
value: String,
value: ProviderInput,
},
EnvironmentInstructions,
Configuring(RequestId),
@@ -177,7 +178,11 @@ impl BedrockState {
)
}
fn active_input_mut(&mut self) -> Option<&mut String> {
pub(super) fn is_configuring(&self, id: &RequestId) -> bool {
matches!(&self.view, BedrockView::Configuring(active) if active == id)
}
pub(super) fn active_input_mut(&mut self) -> Option<&mut ProviderInput> {
match &mut self.view {
BedrockView::ProfileEntry(value)
| BedrockView::ApiKeyEntry(value)
@@ -190,8 +195,11 @@ impl BedrockState {
}
}
fn enter_region(&mut self, credential: BedrockCredential, value: String) {
self.view = BedrockView::RegionEntry { credential, value };
fn enter_region(&mut self, credential: BedrockCredential, value: &str) {
self.view = BedrockView::RegionEntry {
credential,
value: value.into(),
};
}
fn select_method(&mut self, method: BedrockMethod) -> Option<BedrockAction> {
@@ -203,7 +211,7 @@ impl BedrockState {
if let Some(region) = profile.region {
return Some(BedrockAction::Configure(credential, region));
}
self.enter_region(credential, String::new());
self.enter_region(credential, "");
}
BedrockMethod::Environment => {
let credential = BedrockCredential::Environment;
@@ -214,13 +222,13 @@ impl BedrockState {
{
return Some(BedrockAction::Configure(credential, region));
}
self.enter_region(credential, String::new());
self.enter_region(credential, "");
}
BedrockMethod::OtherMethods => {
self.view = BedrockView::Methods(BedrockMethodList::All);
}
BedrockMethod::ManualProfile => {
self.view = BedrockView::ProfileEntry(String::new());
self.view = BedrockView::ProfileEntry(ProviderInput::default());
}
BedrockMethod::AccessKeys => {
self.view = BedrockView::AccessKeyEntry {
@@ -232,7 +240,7 @@ impl BedrockState {
self.view = BedrockView::EnvironmentInstructions;
}
BedrockMethod::ApiKey => {
self.view = BedrockView::ApiKeyEntry(String::new());
self.view = BedrockView::ApiKeyEntry(ProviderInput::default());
}
}
None
@@ -293,57 +301,40 @@ impl BedrockState {
return self.confirm_input();
}
if let Some(input) = self.active_input_mut() {
match key_event.code {
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(character)
if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !key_event.modifiers.intersects(
KeyModifiers::SUPER | KeyModifiers::CONTROL | KeyModifiers::ALT,
) =>
{
input.push(character);
}
_ => {}
}
input.input(*key_event);
}
None
}
fn confirm_input(&mut self) -> Option<BedrockAction> {
let credential = match &mut self.view {
BedrockView::ProfileEntry(value) if !value.trim().is_empty() => {
BedrockCredential::Profile(value.trim().to_string())
}
BedrockView::ApiKeyEntry(value) if !value.trim().is_empty() => {
BedrockCredential::ApiKey(value.trim().to_string())
}
BedrockView::RegionEntry { credential, value } if !value.trim().is_empty() => {
return Some(BedrockAction::Configure(
credential.clone(),
value.trim().to_string(),
));
BedrockView::ProfileEntry(value) => BedrockCredential::Profile(value.value()?),
BedrockView::ApiKeyEntry(value) => BedrockCredential::ApiKey(value.value()?),
BedrockView::RegionEntry { credential, value } => {
return Some(BedrockAction::Configure(credential.clone(), value.value()?));
}
BedrockView::AccessKeyEntry {
values,
selected_field,
} => {
if *selected_field < 2 {
if !values[*selected_field].trim().is_empty() {
if values[*selected_field].value().is_some() {
*selected_field += 1;
}
return None;
}
if values[0].trim().is_empty() || values[1].trim().is_empty() {
*selected_field = if values[0].trim().is_empty() { 0 } else { 1 };
let Some(access_key_id) = values[0].value() else {
*selected_field = 0;
return None;
}
};
let Some(secret_access_key) = values[1].value() else {
*selected_field = 1;
return None;
};
BedrockCredential::AccessKeys {
access_key_id: values[0].trim().to_string(),
secret_access_key: values[1].trim().to_string(),
session_token: (!values[2].trim().is_empty())
.then(|| values[2].trim().to_string()),
access_key_id,
secret_access_key,
session_token: values[2].value(),
}
}
_ => return None,
@@ -357,7 +348,7 @@ impl BedrockState {
{
return Some(BedrockAction::Configure(credential, region));
}
self.enter_region(credential, String::new());
self.enter_region(credential, "");
None
}
@@ -414,30 +405,22 @@ impl BedrockState {
BedrockView::ProfileEntry(value) => {
lines.push(" Enter the name of your AWS profile.".into());
lines.push("".into());
lines.push(Line::from(vec![
" AWS profile: ".into(),
value.clone().cyan(),
]));
lines.push(value.line(" AWS profile: ", "", FieldDisplay::Focused, area.width));
}
BedrockView::ApiKeyEntry(value) => {
lines.push(" Enter your Amazon Bedrock API key.".into());
lines.push("".into());
let mut masked_value = "•".repeat(value.chars().count().saturating_sub(1));
if let Some(character) = value.chars().last() {
masked_value.push(character);
}
lines.push(Line::from(vec![
" Bedrock API key: ".into(),
masked_value.cyan(),
]));
lines.push(value.line(
" Bedrock API key: ",
"",
FieldDisplay::FocusedSecret,
area.width,
));
}
BedrockView::RegionEntry { value, .. } => {
lines.push(" Enter the AWS Region to use with Amazon Bedrock.".into());
lines.push("".into());
lines.push(Line::from(vec![
" AWS Region: ".into(),
value.clone().cyan(),
]));
lines.push(value.line(" AWS Region: ", "", FieldDisplay::Focused, area.width));
}
BedrockView::AccessKeyEntry {
values,
@@ -454,23 +437,18 @@ impl BedrockState {
.enumerate()
{
let marker = if index == *selected_field { ">" } else { " " };
let value = if index == 0 {
values[index].clone()
} else if index == *selected_field {
let mut masked_value =
"•".repeat(values[index].chars().count().saturating_sub(1));
if let Some(character) = values[index].chars().last() {
masked_value.push(character);
}
masked_value
} else {
"•".repeat(values[index].chars().count())
let display = match (index == *selected_field, index == 0) {
(true, true) => FieldDisplay::Focused,
(false, true) => FieldDisplay::Unfocused,
(true, false) => FieldDisplay::FocusedSecret,
(false, false) => FieldDisplay::UnfocusedSecret,
};
let line = format!("{marker} {label}: {value}");
let line =
values[index].line(&format!("{marker} {label}: "), "", display, area.width);
lines.push(if index == *selected_field {
line.cyan().into()
line.cyan()
} else {
line.into()
line
});
}
}
@@ -764,35 +742,13 @@ impl AuthModeWidget {
true
}
pub(super) fn handle_bedrock_paste(&mut self, pasted: &str) -> bool {
let pasted = pasted.trim();
if pasted.is_empty() {
return false;
}
let mut guard = self
.sign_in_state
.write()
.unwrap_or_else(PoisonError::into_inner);
let SignInState::Bedrock(state) = &mut *guard else {
return false;
};
let Some(input) = state.active_input_mut() else {
return false;
};
input.push_str(pasted);
*self.error.write().unwrap_or_else(PoisonError::into_inner) = None;
drop(guard);
self.request_frame.schedule_frame();
true
}
fn start_bedrock_setup(
&mut self,
credential: BedrockCredential,
region: String,
mut fallback: BedrockState,
) {
fallback.enter_region(credential.clone(), region.clone());
fallback.enter_region(credential.clone(), &region);
let mut configuring = fallback.clone();
let request_id = onboarding_request_id();
configuring.view = BedrockView::Configuring(request_id.clone());
@@ -802,14 +758,12 @@ impl AuthModeWidget {
.unwrap_or_else(PoisonError::into_inner) = SignInState::Bedrock(configuring);
let request_handle = self.app_server_request_handle.clone();
let sign_in_state = self.sign_in_state.clone();
let error = self.error.clone();
let request_frame = self.request_frame.clone();
tokio::spawn(async move {
let active_request_id = request_id.clone();
let request = async move {
let result = match credential {
BedrockCredential::ApiKey(api_key) => request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: request_id.clone(),
request_id,
params: LoginAccountParams::AmazonBedrock { api_key, region },
})
.await
@@ -822,7 +776,7 @@ impl AuthModeWidget {
}),
BedrockCredential::Profile(profile) => request_handle
.request_typed::<BedrockSetupResponse>(ClientRequest::BedrockSetup {
request_id: request_id.clone(),
request_id,
params: BedrockSetupParams::Profile { profile, region },
})
.await
@@ -830,7 +784,7 @@ impl AuthModeWidget {
.map_err(|error| error.to_string()),
BedrockCredential::Environment => request_handle
.request_typed::<BedrockSetupResponse>(ClientRequest::BedrockSetup {
request_id: request_id.clone(),
request_id,
params: BedrockSetupParams::Environment { region },
})
.await
@@ -842,7 +796,7 @@ impl AuthModeWidget {
session_token,
} => request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: request_id.clone(),
request_id,
params: LoginAccountParams::AmazonBedrockAccessKeys {
access_key_id,
secret_access_key,
@@ -859,32 +813,9 @@ impl AuthModeWidget {
)),
}),
};
let mut guard = sign_in_state
.write()
.unwrap_or_else(PoisonError::into_inner);
if !matches!(
&*guard,
SignInState::Bedrock(state)
if matches!(&state.view, BedrockView::Configuring(active_request_id)
if *active_request_id == request_id)
) {
return;
}
match result {
Ok(()) => {
*error.write().unwrap_or_else(PoisonError::into_inner) = None;
// Startup shows GovCloud guidance after the selected provider is reloaded.
*guard = SignInState::BedrockConfigured;
}
Err(err) => {
*error.write().unwrap_or_else(PoisonError::into_inner) =
Some(format!("Unable to set up Amazon Bedrock: {err}"));
*guard = SignInState::Bedrock(fallback);
}
}
drop(guard);
request_frame.schedule_frame();
});
result.map_err(|err| format!("Unable to set up Amazon Bedrock: {err}"))
};
self.spawn_provider_setup(active_request_id, SignInState::Bedrock(fallback), request);
}
}
+23 -11
View File
@@ -1,4 +1,5 @@
use super::*;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
#[test]
@@ -415,9 +416,9 @@ fn credential_entries_share_region_and_keep_aws_secrets_hidden() {
});
access_keys.view = BedrockView::AccessKeyEntry {
values: [
"AKIAEXAMPLE".to_string(),
"secret-value".to_string(),
"session-value".to_string(),
"AKIAEXAMPLE".into(),
"secret-value".into(),
"session-value".into(),
],
selected_field: 1,
};
@@ -459,7 +460,18 @@ fn credential_entries_share_region_and_keep_aws_secrets_hidden() {
assert!(!visible.contains("secret-value"));
assert!(!visible.contains("session-value"));
access_keys.view = BedrockView::ApiKeyEntry("bedrock-api-key".to_string());
access_keys.view = BedrockView::ApiKeyEntry("old-key".into());
access_keys.handle_key_event(&KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
for _ in 0..3 {
access_keys.handle_key_event(&KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
}
access_keys.handle_key_event(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
access_keys.active_input_mut().unwrap().paste("bedrock-api");
assert_eq!(
access_keys.active_input_mut().unwrap().value().as_deref(),
Some("bedrock-api-key")
);
access_keys.handle_key_event(&KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
let visible = render_visible(&access_keys);
insta::assert_snapshot!(visible, @r###"
> Set up Amazon Bedrock
@@ -475,7 +487,7 @@ fn credential_entries_share_region_and_keep_aws_secrets_hidden() {
access_keys.handle_key_event(&KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
assert!(render_visible(&access_keys).contains("Bedrock API key: •••••••••••••••z"));
access_keys.view = BedrockView::ProfileEntry("engineering".to_string());
access_keys.view = BedrockView::ProfileEntry("engineering".into());
insta::assert_snapshot!(render_visible(&access_keys), @r###"
> Set up Amazon Bedrock
@@ -487,7 +499,7 @@ fn credential_entries_share_region_and_keep_aws_secrets_hidden() {
Press esc to go back
"###);
access_keys.enter_region(BedrockCredential::Environment, "us-west-1".to_string());
access_keys.enter_region(BedrockCredential::Environment, "us-west-1");
insta::assert_snapshot!(render_visible(&access_keys), @r###"
> Set up Amazon Bedrock
@@ -515,11 +527,11 @@ fn credential_entries_share_region_and_keep_aws_secrets_hidden() {
"###);
for view in [
BedrockView::ProfileEntry(String::new()),
BedrockView::ApiKeyEntry(String::new()),
BedrockView::ProfileEntry(ProviderInput::default()),
BedrockView::ApiKeyEntry(ProviderInput::default()),
BedrockView::RegionEntry {
credential: BedrockCredential::Environment,
value: String::new(),
value: ProviderInput::default(),
},
BedrockView::AccessKeyEntry {
values: Default::default(),
@@ -540,10 +552,10 @@ fn credential_entries_share_region_and_keep_aws_secrets_hidden() {
},
BedrockCredential::ApiKey("bedrock-api-key".to_string()),
] {
access_keys.enter_region(credential, "us-west-1".to_string());
access_keys.enter_region(credential, "us-west-1");
assert!(matches!(
&access_keys.view,
BedrockView::RegionEntry { value, .. } if value == "us-west-1"
BedrockView::RegionEntry { value, .. } if value.value().as_deref() == Some("us-west-1")
));
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Google Cloud setup collects provider settings and delegates validation and persistence to
//! app-server. Setup stays in progress until the server finishes writing the configuration.
use super::auth::AuthModeWidget;
use super::auth::SignInState;
use super::auth::onboarding_request_id;
use super::keys;
use super::provider_setup::FieldDisplay;
use super::provider_setup::ProviderInput;
use crate::key_hint::KeyBindingListExt;
use crate::wrapping::word_wrap_lines;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::GcpSetupParams;
use codex_app_server_protocol::GcpSetupResponse;
use codex_app_server_protocol::RequestId;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
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 std::sync::PoisonError;
#[derive(Clone, Default)]
pub(super) struct GcpState {
view: GcpView,
project_id: ProviderInput,
location: ProviderInput,
credential_file: ProviderInput,
}
#[derive(Clone, Default)]
enum GcpView {
#[default]
Project,
Location,
Credentials,
Configuring(RequestId),
}
impl GcpState {
pub(super) fn is_text_entry_active(&self) -> bool {
!matches!(self.view, GcpView::Configuring(_))
}
pub(super) fn is_configuring(&self, id: &RequestId) -> bool {
matches!(&self.view, GcpView::Configuring(active) if active == id)
}
pub(super) fn active_input_mut(&mut self) -> Option<&mut ProviderInput> {
match self.view {
GcpView::Project => Some(&mut self.project_id),
GcpView::Location => Some(&mut self.location),
GcpView::Credentials => Some(&mut self.credential_file),
GcpView::Configuring(_) => None,
}
}
pub(super) fn render(&self, area: Rect, buf: &mut Buffer, error: Option<String>) {
let mut lines: Vec<Line> = vec!["> Set up Google Cloud Vertex AI".bold().into(), "".into()];
let (guidance, field) = match &self.view {
GcpView::Project => (
"Enter the Google Cloud project ID to use with Vertex AI.",
Some((&self.project_id, " Project ID: ", "my-project")),
),
GcpView::Location => (
"Press Enter to use global, or enter a Vertex AI location.",
Some((&self.location, " Location (optional): ", "global")),
),
GcpView::Credentials => (
"Enter an absolute file path on the host running Codex, or press Enter to use Application Default Credentials (ADC).",
Some((&self.credential_file, " Credential file (optional): ", "")),
),
GcpView::Configuring(_) => ("Validating credentials and saving configuration…", None),
};
lines.push(format!(" {guidance}").into());
if let Some((input, label, placeholder)) = field {
lines.push("".into());
lines.push(input.line(label, placeholder, FieldDisplay::Focused, area.width));
}
if matches!(self.view, GcpView::Credentials) {
lines.push("".into());
lines.push(
" To set up ADC with the Google Cloud CLI, run"
.dim()
.into(),
);
lines.push(" gcloud auth application-default login".cyan().into());
}
let mut footer = if self.is_text_entry_active() {
vec![Line::from(vec![
" ".into(),
keys::CONFIRM[0].into(),
if matches!(self.view, GcpView::Credentials) {
" set up"
} else {
" continue"
}
.dim(),
" · ".dim(),
keys::CANCEL[0].into(),
" back".dim(),
])]
} else {
Vec::new()
};
if let Some(error) = error {
footer.push("".into());
footer.push(error.red().into());
}
let footer = word_wrap_lines(footer, usize::from(area.width));
let footer_height = u16::try_from(footer.len())
.unwrap_or(u16::MAX)
.min(area.height);
Paragraph::new(word_wrap_lines(lines, usize::from(area.width))).render(
Rect {
height: area.height.saturating_sub(footer_height),
..area
},
buf,
);
Paragraph::new(footer).render(
Rect {
y: area.bottom().saturating_sub(footer_height),
height: footer_height,
..area
},
buf,
);
}
}
impl AuthModeWidget {
pub(super) fn handle_gcp_key_event(&mut self, key_event: &KeyEvent) -> bool {
let mut guard = self
.sign_in_state
.write()
.unwrap_or_else(PoisonError::into_inner);
let SignInState::Gcp(state) = &mut *guard else {
return false;
};
// A dispatched setup can persist settings. Do not offer cancellation or allow a
// second submission until its result arrives.
if key_event.kind == KeyEventKind::Release || !state.is_text_entry_active() {
return true;
}
if keys::CANCEL.is_pressed(*key_event) {
match state.view {
GcpView::Project => *guard = SignInState::PickMode,
GcpView::Location => state.view = GcpView::Project,
GcpView::Credentials => state.view = GcpView::Location,
GcpView::Configuring(_) => {}
}
} else if keys::CONFIRM.is_pressed(*key_event) {
if key_event.kind != KeyEventKind::Press {
return true;
}
match state.view {
GcpView::Project if state.project_id.value().is_none() => {
*self.error.write().unwrap_or_else(PoisonError::into_inner) =
Some("Enter a Google Cloud project ID.".to_string());
self.request_frame.schedule_frame();
return true;
}
GcpView::Project => state.view = GcpView::Location,
GcpView::Location => state.view = GcpView::Credentials,
GcpView::Credentials => {
let request_id = onboarding_request_id();
let retry = SignInState::Gcp(state.clone());
state.view = GcpView::Configuring(request_id.clone());
let params = GcpSetupParams {
project_id: state.project_id.value().unwrap_or_default(),
location: state.location.value(),
credential_file: state.credential_file.value(),
};
let request_handle = self.app_server_request_handle.clone();
self.spawn_provider_setup(request_id.clone(), retry, async move {
request_handle
.request_typed::<GcpSetupResponse>(ClientRequest::GcpSetup {
request_id,
params,
})
.await
.map(|_| ())
.map_err(|err| match err {
TypedRequestError::Server { source, .. } => source.message,
err => err.to_string(),
})
});
}
GcpView::Configuring(_) => {}
}
} else if let Some(input) = state.active_input_mut() {
input.input(*key_event);
}
*self.error.write().unwrap_or_else(PoisonError::into_inner) = None;
drop(guard);
self.request_frame.schedule_frame();
true
}
}
+186
View File
@@ -0,0 +1,186 @@
#![cfg(test)]
//! Exercises Google Cloud onboarding through the widget and embedded app-server.
use super::*;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::MergeStrategy;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use serde_json::json;
#[tokio::test]
async fn gcp_menu_gating_and_setup_flow() {
let (mut widget, home) = super::tests::widget_forced_chatgpt().await;
widget.auth_config.forced_login_method = None;
widget.handle_sign_in_option(SignInOption::Gcp);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
let snapshot = |widget: &AuthModeWidget, name: &str, area: Rect| {
let mut terminal = crate::custom_terminal::Terminal::with_options(
crate::test_backend::VT100Backend::new(area.width, area.height),
)
.unwrap();
terminal.set_viewport_area(area);
terminal
.draw(|frame| widget.render_ref(area, frame.buffer_mut()))
.unwrap();
insta::assert_snapshot!(name, terminal.backend().to_string());
};
snapshot(
&widget,
"sign_in_menu_short_without_cloud_wizards",
Rect::new(0, 0, 76, 8),
);
widget.gcp_setup_enabled = true;
assert_eq!(
widget.displayed_sign_in_options(),
vec![
SignInOption::ChatGpt,
SignInOption::DeviceCode,
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
]
);
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));
let area = Rect::new(0, 0, 76, 12);
widget.auth_config.forced_login_method = Some(ForcedLoginMethod::Chatgpt);
assert_eq!(
widget.displayed_sign_in_options(),
vec![SignInOption::ChatGpt, SignInOption::DeviceCode]
);
widget.handle_sign_in_option(SignInOption::Gcp);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
widget.auth_config.forced_login_method = None;
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::pending("login".into()));
widget.handle_key_event(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE));
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));
assert!(widget.is_text_entry_active());
assert!(widget.should_suppress_printable_quit());
widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
widget.error_message().as_deref(),
Some("Enter a Google Cloud project ID.")
);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
widget.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
widget.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER));
widget.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER));
widget.handle_paste("test-ject\n".to_string());
for _ in 0..4 {
widget.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
}
widget.handle_paste("pro".to_string());
widget.handle_key_event(KeyEvent::new(
KeyCode::Char('u'),
KeyModifiers::SUPER | KeyModifiers::CONTROL,
));
snapshot(&widget, "gcp_project_entry", area);
widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
snapshot(&widget, "gcp_location_entry", area);
// Location accepts text normally, and Esc preserves it for the next visit.
widget.handle_paste("us-central1".to_string());
widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
widget.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
snapshot(&widget, "gcp_credentials_entry", area);
// A relative credential path fails server validation without requiring real GCP credentials.
widget.handle_paste("relative-adc.json".to_string());
let (request_frame, mut frames) = FrameRequester::test_channel();
widget.request_frame = request_frame;
widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(!widget.is_text_entry_active());
widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(!widget.is_text_entry_active());
snapshot(&widget, "gcp_configuring", area);
while !widget.is_text_entry_active() {
frames.recv().await.unwrap();
}
let error = widget.error_message().unwrap();
assert!(error.contains("absolute"), "{error}");
assert!(!home.path().join("config.toml").exists());
snapshot(&widget, "gcp_setup_error_narrow", Rect::new(0, 0, 40, 18));
// Configure the existing command-auth boundary to avoid invoking gcloud in the test.
let (command, args) = if cfg!(windows) {
("cmd", vec!["/C", "echo setup-token"])
} else {
("echo", vec!["setup-token"])
};
widget
.app_server_request_handle
.request_typed::<ConfigWriteResponse>(ClientRequest::ConfigBatchWrite {
request_id: onboarding_request_id(),
params: ConfigBatchWriteParams {
edits: vec![ConfigEdit {
key_path: "model_providers.gcp.auth".to_string(),
value: json!({"command": command, "args": args, "cwd": home.path()}),
merge_strategy: MergeStrategy::Replace,
}],
file_path: None,
expected_version: None,
reload_user_config: false,
},
})
.await
.unwrap();
widget.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
while matches!(&*widget.sign_in_state.read().unwrap(), SignInState::Gcp(_))
&& widget.error_message().is_none()
{
frames.recv().await.unwrap();
}
assert_eq!(widget.error_message(), None);
assert!(matches!(widget.get_step_state(), StepState::Complete));
snapshot(&widget, "gcp_configured", area);
let config: toml::Value =
toml::from_str(&std::fs::read_to_string(home.path().join("config.toml")).unwrap()).unwrap();
assert_eq!(
config,
toml::Value::try_from(json!({
"model_provider": "gcp",
"model_providers": {"gcp": {
"project_id": "test-project", "location": "global",
"auth": {"command": command, "args": args, "cwd": home.path()},
}},
}))
.unwrap()
);
}
+1
View File
@@ -24,6 +24,7 @@ pub(crate) const SELECT_SECOND: [KeyBinding; 2] = [
];
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'))];
+2
View File
@@ -1,8 +1,10 @@
mod auth;
mod bedrock;
mod gcp;
pub(crate) use bedrock::check_gov_cloud;
mod keys;
pub(crate) mod onboarding_screen;
mod provider_setup;
mod trust_directory;
pub(crate) use auth::mark_underlined_hyperlink;
pub(crate) use auth::mark_url_hyperlink;
@@ -106,6 +106,7 @@ pub(crate) struct OnboardingScreenArgs {
pub remote_project_trust: Option<RemoteProjectTrust>,
pub show_login_screen: bool,
pub bedrock_setup_enabled: bool,
pub gcp_setup_enabled: bool,
pub login_status: LoginStatus,
pub app_server_target: crate::AppServerTarget,
pub app_server_request_handle: Option<AppServerRequestHandle>,
@@ -133,6 +134,7 @@ impl OnboardingScreen {
remote_project_trust,
show_login_screen,
bedrock_setup_enabled,
gcp_setup_enabled,
login_status,
app_server_target,
app_server_request_handle,
@@ -184,6 +186,7 @@ impl OnboardingScreen {
app_server_request_handle,
auth_config,
bedrock_setup_enabled,
gcp_setup_enabled,
animations_enabled: local_settings.tui.animations
&& local_settings.tui.effects.shimmer,
animations_suppressed: std::cell::Cell::new(false),
@@ -346,7 +349,7 @@ impl KeyboardHandler for OnboardingScreen {
/// Route key events to onboarding steps while preserving text-entry safety.
///
/// OpenAI API-key entry suppresses printable quit bindings after typing
/// starts, while Bedrock fields suppress them from the first character.
/// 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) {
@@ -0,0 +1,150 @@
//! Single-line fields and asynchronous setup completion shared by cloud provider wizards.
use super::auth::AuthModeWidget;
use super::auth::SignInState;
use crate::bottom_pane::TextArea;
use codex_app_server_protocol::RequestId;
use crossterm::event::KeyEvent;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use std::future::Future;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::MutexGuard;
use std::sync::PoisonError;
use unicode_segmentation::UnicodeSegmentation;
// The sign-in state is shared with request tasks. Each field owns its editing history,
// including killed text, so yanking in another field cannot reveal a credential.
#[derive(Clone)]
pub(super) struct ProviderInput(Arc<Mutex<TextArea>>);
pub(super) enum FieldDisplay {
Focused,
Unfocused,
FocusedSecret,
UnfocusedSecret,
}
impl Default for ProviderInput {
fn default() -> Self {
Self::from("")
}
}
impl From<&str> for ProviderInput {
fn from(text: &str) -> Self {
let mut editor = TextArea::new_single_line();
editor.insert_str(text);
Self(Arc::new(Mutex::new(editor)))
}
}
impl ProviderInput {
fn editor(&self) -> MutexGuard<'_, TextArea> {
self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(super) fn input(&mut self, event: KeyEvent) {
self.editor().input(event);
}
pub(super) fn paste(&mut self, pasted: &str) {
let text: String = pasted.chars().filter(|c| !c.is_control()).collect();
self.editor().insert_str(&text);
}
pub(super) fn value(&self) -> Option<String> {
let editor = self.editor();
let text = editor.text().trim();
(!text.is_empty()).then(|| text.to_owned())
}
pub(super) fn line(
&self,
label: &str,
placeholder: &str,
display: FieldDisplay,
width: u16,
) -> Line<'static> {
let editor = self.editor();
let mut line = Line::from(label.to_owned());
if editor.text().is_empty() && !placeholder.is_empty() {
line.push_span(placeholder.to_owned().dim());
return line;
}
let focused = matches!(display, FieldDisplay::Focused | FieldDisplay::FocusedSecret);
let field_width = usize::from(width).saturating_sub(line.width());
// When the label occupies a whole row, wrapping moves the value to the next row.
let mut remaining = if field_width == 0 {
usize::from(width)
} else {
field_width
};
let start = if focused {
editor.single_line_viewport(remaining as u16).0
} else {
0
};
let mut content = editor.text()[start..].grapheme_indices(true).peekable();
while let Some((offset, grapheme)) = content.next() {
let cell = match display {
FieldDisplay::Focused | FieldDisplay::Unfocused => grapheme,
FieldDisplay::FocusedSecret if content.peek().is_none() => grapheme,
FieldDisplay::FocusedSecret | FieldDisplay::UnfocusedSecret => "•",
};
let span = Span::raw(cell.to_owned());
let span = if focused { span.cyan() } else { span };
let Some(rest) = remaining.checked_sub(span.width()) else {
break;
};
remaining = rest;
line.push_span(if focused && start + offset == editor.cursor() {
span.reversed()
} else {
span
});
}
if focused && remaining > 0 && editor.cursor() == editor.text().len() {
line.push_span(" ".reversed());
}
line
}
}
impl AuthModeWidget {
pub(super) fn spawn_provider_setup(
&self,
request_id: RequestId,
retry: SignInState,
request: impl Future<Output = Result<(), String>> + Send + 'static,
) {
let sign_in_state = self.sign_in_state.clone();
let error = self.error.clone();
let request_frame = self.request_frame.clone();
tokio::spawn(async move {
let result = request.await;
let mut state = sign_in_state
.write()
.unwrap_or_else(PoisonError::into_inner);
let configured = match &*state {
SignInState::Bedrock(bedrock) if bedrock.is_configuring(&request_id) => {
SignInState::BedrockConfigured
}
SignInState::Gcp(gcp) if gcp.is_configuring(&request_id) => {
SignInState::GcpConfigured
}
_ => return,
};
let (next, message) = match result {
Ok(()) => (configured, None),
Err(err) => (retry, Some(err)),
};
*state = next;
*error.write().unwrap_or_else(PoisonError::into_inner) = message;
drop(state);
request_frame.schedule_frame();
});
}
}
@@ -0,0 +1,5 @@
---
source: tui/src/onboarding/gcp_tests.rs
expression: terminal.backend().to_string()
---
✓ Google Cloud Vertex AI configured
@@ -0,0 +1,7 @@
---
source: tui/src/onboarding/gcp_tests.rs
expression: terminal.backend().to_string()
---
> Set up Google Cloud Vertex AI
Validating credentials and saving configuration…
@@ -0,0 +1,16 @@
---
source: tui/src/onboarding/gcp_tests.rs
expression: terminal.backend().to_string()
---
> Set up Google Cloud Vertex AI
Enter an absolute file path on the host running Codex, or press Enter to
use Application Default Credentials (ADC).
Credential file (optional):
To set up ADC with the Google Cloud CLI, run
gcloud auth application-default login
enter set up · esc back
@@ -0,0 +1,16 @@
---
source: tui/src/onboarding/gcp_tests.rs
expression: terminal.backend().to_string()
---
> Set up Google Cloud Vertex AI
Press Enter to use global, or enter a Vertex AI location.
Location (optional): global
enter continue · esc back
@@ -0,0 +1,16 @@
---
source: tui/src/onboarding/gcp_tests.rs
expression: terminal.backend().to_string()
---
> Set up Google Cloud Vertex AI
Enter the Google Cloud project ID to use with Vertex AI.
Project ID: test-project
enter continue · esc back
@@ -0,0 +1,22 @@
---
source: tui/src/onboarding/gcp_tests.rs
expression: terminal.backend().to_string()
---
> Set up Google Cloud Vertex AI
Enter an absolute file path on the
host running Codex, or press Enter to
use Application Default Credentials
(ADC).
Credential file (optional): -adc.json
To set up ADC with the Google Cloud
CLI, run
gcloud auth application-default login
enter set up · esc back
provider credential_file must be an
absolute path
@@ -0,0 +1,22 @@
---
source: tui/src/onboarding/gcp_tests.rs
expression: terminal.backend().to_string()
---
Choose how you want to use Codex.
> 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
Pay for what you use
4. Use Amazon Bedrock
Connect using your AWS credentials
5. Use Google Cloud Vertex AI
Connect using your Google Cloud credentials
Press enter to continue
@@ -0,0 +1,12 @@
---
source: tui/src/onboarding/gcp_tests.rs
expression: terminal.backend().to_string()
---
Choose how you want to use Codex.
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
Press enter to continue
@@ -0,0 +1,11 @@
---
source: tui/src/onboarding/gcp_tests.rs
expression: terminal.backend().to_string()
---
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
2. Sign in with Device Code
3. Use an OpenAI API key
Press enter to continue
+13 -8
View File
@@ -575,12 +575,17 @@ pub(super) async fn run_main_inner(
&& !app_server_target.uses_remote_workspace();
if auto_start_daemon
&& daemon_exclusion.is_none()
&& should_show_bedrock_setup_wizard(
LoginStatus::NotAuthenticated,
config.model_provider.requires_openai_auth,
&config,
&AppServerTarget::Embedded,
)
&& [Feature::BedrockSetupWizard, Feature::GcpSetupWizard]
.into_iter()
.any(|feature| {
should_show_provider_setup_wizard(
LoginStatus::NotAuthenticated,
config.model_provider.requires_openai_auth,
&config,
&AppServerTarget::Embedded,
feature,
)
})
&& startup_draft
.run_until(
config
@@ -592,8 +597,8 @@ pub(super) async fn run_main_inner(
.flatten()
.is_none()
{
// The Bedrock wizard configures its provider through the embedded server.
daemon_exclusion = Some("Bedrock sign-in");
// Provider setup wizards configure their provider through the embedded server.
daemon_exclusion = Some("Cloud provider sign-in");
app_server_target = AppServerTarget::Embedded;
}
if auto_start_daemon