mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(), ®ion);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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'))];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/onboarding/gcp_tests.rs
|
||||
expression: terminal.backend().to_string()
|
||||
---
|
||||
✓ Google Cloud Vertex AI configured
|
||||
+7
@@ -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…
|
||||
+16
@@ -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
|
||||
+16
@@ -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
|
||||
+16
@@ -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
|
||||
+22
@@ -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
|
||||
+22
@@ -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
|
||||
+12
@@ -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
|
||||
+11
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user