diff --git a/codex-rs/app-server/src/config_manager_service_provider_write.rs b/codex-rs/app-server/src/config_manager_service_provider_write.rs index 3b956a8df6..d8da300b2c 100644 --- a/codex-rs/app-server/src/config_manager_service_provider_write.rs +++ b/codex-rs/app-server/src/config_manager_service_provider_write.rs @@ -91,7 +91,7 @@ fn provider_edits( .is_some_and(|id| builtins.contains_key(id) || id == LEGACY_OLLAMA_CHAT_PROVIDER_ID) { return Err(invalid( - "A custom gateway cannot use a built-in provider ID.", + "A custom provider or gateway cannot use a built-in provider ID.", )); } let existing = target.and_then(|id| configured?.get(id)); diff --git a/codex-rs/app-server/tests/suite/v2/model_provider_config_tests.rs b/codex-rs/app-server/tests/suite/v2/model_provider_config_tests.rs index 9ef4cdd712..a27f49d045 100644 --- a/codex-rs/app-server/tests/suite/v2/model_provider_config_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/model_provider_config_tests.rs @@ -157,6 +157,11 @@ async fn custom_provider_edits_preserve_other_fields_and_respect_policy() -> Res "configValidationError", ), ("basic", json!({"envKey": null}), "configValidationError"), + ( + "ollama-chat", + json!({"name": "Renamed"}), + "configValidationError", + ), ("ollama", json!({}), "configValidationError"), ("gcp", json!({}), "configValidationError"), ] { @@ -171,6 +176,12 @@ async fn custom_provider_edits_preserve_other_fields_and_respect_policy() -> Res let error = server .read_stream_until_error_message(RequestId::Integer(edit)) .await?; + if id == "ollama-chat" { + insta::assert_snapshot!( + error.error.message, + @"A custom provider or gateway cannot use a built-in provider ID." + ); + } assert_eq!( error.error.data, Some(json!({"config_write_error_code": error_code})) diff --git a/codex-rs/tui/src/app/provider.rs b/codex-rs/tui/src/app/provider.rs index 2281789bbe..95b71752c4 100644 --- a/codex-rs/tui/src/app/provider.rs +++ b/codex-rs/tui/src/app/provider.rs @@ -24,6 +24,8 @@ use std::sync::Arc; use tokio::sync::watch; mod editor; +pub(crate) use editor::Draft; +pub(crate) use editor::ProviderForm; mod fields; pub(crate) mod rpc; mod write; @@ -32,6 +34,7 @@ use fields::provider_fields; const LOADING_VIEW: &str = "model_provider_loading"; pub(super) const LIST_VIEW: &str = "model_provider_list"; +pub(crate) const CREATE_CUSTOM_PROVIDER: &str = "Create a custom provider or gateway"; #[derive(Debug)] pub(crate) enum Event { @@ -314,7 +317,7 @@ fn list( }) .collect(); items.push(SelectionItem { - name: "Add custom provider".into(), + name: CREATE_CUSTOM_PROVIDER.into(), footer_hint: Some(crate::bottom_pane::popup_consts::picker_hint_line_for_keymap(keymap)), actions: vec![Box::new({ let catalog = Arc::clone(&catalog); diff --git a/codex-rs/tui/src/app/provider/editor.rs b/codex-rs/tui/src/app/provider/editor.rs index 0394389728..a79b3b440a 100644 --- a/codex-rs/tui/src/app/provider/editor.rs +++ b/codex-rs/tui/src/app/provider/editor.rs @@ -1,10 +1,10 @@ //! Single-screen provider form. Validation preserves the draft and each field owns its editing history. +use super::CREATE_CUSTOM_PROVIDER; use super::Field; use crate::app::App; use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::CancellationEvent; -use crate::bottom_pane::TextArea; use crate::bottom_pane::ViewCompletion; use crate::key_hint; use crate::keymap::KeymapContextSet; @@ -25,7 +25,6 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; -use std::sync::MutexGuard; mod form; pub(crate) use form::Draft; @@ -38,40 +37,62 @@ pub(crate) fn view( error: Option, on_save: impl Fn(Draft, ModelProviderConfigWriteParams) + Send + 'static, ) -> Box { - let mut inputs = Vec::new(); - for (field, original) in draft.fields() { - let input = ProviderInput::from(draft.value(field, original)); + let form = ProviderForm::new(draft, error); + for (_, input) in &form.inputs { let mut editor = input.editor(); editor.set_keymap_bindings(&app.keymap); if app.chat_widget.composer_is_vim_enabled() { editor.set_vim_enabled(/*enabled*/ true); editor.enter_vim_insert_mode(); } - drop(editor); - inputs.push((field, input)); } Box::new(Editor { - inputs, - draft, + form, on_save: Box::new(on_save), - error, completion: None, }) } struct Editor { - inputs: Vec<(Field, ProviderInput)>, - draft: Draft, + form: ProviderForm, on_save: Box, - error: Option, completion: Option, } -impl Editor { - fn active_input(&self) -> Option> { +pub(crate) struct ProviderForm { + inputs: Vec<(Field, ProviderInput)>, + draft: Draft, + pub(crate) error: Option, +} + +struct FormLayout { + paragraph: Paragraph<'static>, + focus_row: u16, + cursor: Option<(u16, u16)>, +} + +impl ProviderForm { + pub(crate) fn new(draft: Draft, error: Option) -> Self { + let inputs = draft + .fields() + .into_iter() + .map(|(field, original)| (field, ProviderInput::from(draft.value(field, original)))) + .collect(); + Self { + inputs, + draft, + error, + } + } + + pub(crate) fn active_input_mut(&mut self) -> Option<&mut ProviderInput> { self.inputs - .get(self.draft.selected) - .map(|(_, input)| input.editor()) + .get_mut(self.draft.selected) + .map(|(_, input)| input) + } + + pub(crate) fn active_input(&self) -> Option<&ProviderInput> { + self.inputs.get(self.draft.selected).map(|(_, input)| input) } fn changes(&self) -> impl Iterator { @@ -87,12 +108,43 @@ impl Editor { }) } - fn save(&mut self) { - self.draft.changes = self.changes().collect(); - match self.draft.patch() { - Ok(params) => (self.on_save)(self.draft.clone(), params), - Err(error) => self.error = Some(error.to_owned()), + pub(crate) fn input(&mut self, key: KeyEvent) -> Option { + if move_provider_field(key, &mut self.draft.selected, self.inputs.len() + 1) { + return None; } + // Holding Enter on a field must not carry its activation onto Save. + if key.kind == KeyEventKind::Press && key_hint::plain(KeyCode::Enter).is_press(key) { + if self.draft.selected == self.inputs.len() { + self.draft.changes = self.changes().collect(); + match self.draft.patch() { + Ok(params) => return Some(params), + Err(message) => self.error = Some(message.into()), + } + } else { + self.draft.selected += 1; + } + return None; + } + if key_hint::ctrl(KeyCode::Char(' ')).is_press(key) + && let Some(suggestion) = self.suggestion() + { + let mut editor = self.inputs[self.draft.selected].1.editor(); + let suffix = &suggestion[editor.cursor()..]; + editor.insert_str(suffix); + return None; + } + if let Some((_, input)) = self.inputs.get_mut(self.draft.selected) { + input.input(key); + } + None + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + self.active_input().is_some_and(|input| { + input + .editor() + .should_handle_vim_insert_escape(KeyCode::Esc.into()) + }) } fn suggestion(&self) -> Option<&str> { @@ -111,15 +163,16 @@ impl Editor { .find(|name| *name != prefix && name.starts_with(prefix)) } - fn layout(&self, width: u16) -> (Paragraph<'static>, Option<(u16, u16)>) { + fn layout(&self, width: u16) -> FormLayout { let title = self.draft.provider.as_ref().map_or_else( - || "Add custom provider".to_owned(), + || CREATE_CUSTOM_PROVIDER.to_owned(), |p| format!("Edit {}", p.name), ); let wrap_width = usize::from(width.max(1)); let mut lines = word_wrap_lines([Line::from(title.bold())], wrap_width); lines.push(Line::default()); let mut cursor = None; + let mut focus_row = 0; let key_needs_url = !self.draft.required(Field::Url) && self .changes() @@ -157,6 +210,7 @@ impl Editor { let x = line.width() as u16; let area = Rect::new(x, lines.len() as u16, width.saturating_sub(x), 1); if focused { + focus_row = area.y; cursor = input.editor().cursor_pos(area); } let value = input.line("", example, display, area.width); @@ -164,10 +218,14 @@ impl Editor { lines.push(truncate_line_to_width(line, usize::from(width))); } lines.push(if self.draft.selected == self.inputs.len() { + focus_row = lines.len() as u16; "› Save".bold().into() } else { " Save".into() }); + if let Some(error) = &self.error { + lines.push(error.clone().red().into()); + } lines.push(Line::default()); let has = |field| self.inputs.iter().any(|(key, _)| *key == field); if has(Field::Name) { @@ -184,9 +242,6 @@ impl Editor { format!(" complete {suggestion}").dim(), ])); } - if let Some(error) = &self.error { - lines.push(error.clone().red().into()); - } lines.push(Line::from(vec![ key_hint::plain(KeyCode::Tab).into(), " move · ".dim(), @@ -200,28 +255,53 @@ impl Editor { } .dim(), ])); - (Paragraph::new(lines).wrap(Wrap { trim: false }), cursor) + FormLayout { + paragraph: Paragraph::new(lines).wrap(Wrap { trim: false }), + focus_row, + cursor, + } + } + + fn visible_layout(&self, area: Rect) -> (Paragraph<'static>, Option<(u16, u16)>) { + let FormLayout { + paragraph, + cursor, + focus_row, + } = self.layout(area.width); + // Show as much feedback below Save as fits while keeping the active action visible. + let top = if self.draft.selected == self.inputs.len() { + paragraph + .desired_height(area.width) + .saturating_sub(area.height) + .min(focus_row) + } else { + focus_row.saturating_add(1).saturating_sub(area.height) + }; + ( + paragraph.scroll((top, 0)), + cursor.map(|(x, y)| (x, y.saturating_sub(top))), + ) } } -impl Renderable for Editor { +impl Renderable for ProviderForm { fn render(&self, area: Rect, buf: &mut Buffer) { - self.layout(area.width).0.render(area, buf); + self.visible_layout(area).0.render(area, buf); } fn desired_height(&self, width: u16) -> u16 { - self.layout(width).0.desired_height(width) + self.layout(width).paragraph.desired_height(width) } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let (x, y) = self.layout(area.width).1?; + let (x, y) = self.visible_layout(area).1?; (y < area.height).then(|| (area.x.saturating_add(x), area.y.saturating_add(y))) } fn cursor_style(&self, _area: Rect) -> SetCursorStyle { if self .active_input() - .is_some_and(|editor| editor.uses_vim_insert_cursor()) + .is_some_and(|input| input.editor().uses_vim_insert_cursor()) { SetCursorStyle::SteadyBar } else { @@ -230,39 +310,34 @@ impl Renderable for Editor { } } +impl Renderable for Editor { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.form.render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + self.form.desired_height(width) + } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.form.cursor_pos(area) + } + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + self.form.cursor_style(area) + } +} + impl BottomPaneView for Editor { fn view_id(&self) -> Option<&'static str> { Some(VIEW_ID) } fn handle_key_event(&mut self, key: KeyEvent) { - if move_provider_field(key, &mut self.draft.selected, self.inputs.len() + 1) { - return; - } - // Holding Enter on a field must not carry its activation onto Save. - if key.kind == KeyEventKind::Press && key_hint::plain(KeyCode::Enter).is_press(key) { - if self.draft.selected == self.inputs.len() { - self.save(); - } else { - self.draft.selected += 1; - } - return; - } - if key_hint::ctrl(KeyCode::Char(' ')).is_press(key) - && let Some(suggestion) = self.suggestion() - { - let mut editor = self.inputs[self.draft.selected].1.editor(); - let suffix = &suggestion[editor.cursor()..]; - editor.insert_str(suffix); - return; - } - if let Some((_, input)) = self.inputs.get_mut(self.draft.selected) { - input.input(key); + if let Some(params) = self.form.input(key) { + (self.on_save)(self.form.draft.clone(), params); } } fn handle_paste(&mut self, pasted: String) -> bool { - if let Some((_, input)) = self.inputs.get_mut(self.draft.selected) { + if let Some(input) = self.form.active_input_mut() { input.paste(&pasted); return true; } @@ -270,14 +345,14 @@ impl BottomPaneView for Editor { } fn keymap_contexts(&self) -> KeymapContextSet { - self.active_input() - .map(|editor| KeymapContextSet::new(editor.keymap_context())) + self.form + .active_input() + .map(|input| KeymapContextSet::new(input.editor().keymap_context())) .unwrap_or_default() } fn prefer_esc_to_handle_key_event(&self) -> bool { - self.active_input() - .is_some_and(|editor| editor.should_handle_vim_insert_escape(KeyCode::Esc.into())) + self.form.prefer_esc_to_handle_key_event() } fn on_ctrl_c(&mut self) -> CancellationEvent { diff --git a/codex-rs/tui/src/app/provider/editor_tests.rs b/codex-rs/tui/src/app/provider/editor_tests.rs index 71adfc4610..6832fd50e7 100644 --- a/codex-rs/tui/src/app/provider/editor_tests.rs +++ b/codex-rs/tui/src/app/provider/editor_tests.rs @@ -135,10 +135,10 @@ async fn entering_a_key_for_a_legacy_gateway_prompts_for_its_address() { Base URL* https://gateway.example/v1 API key variable $TEAM_KEY › Save + Enter a base URL to use this API key variable. * Required Enter the environment variable name on the app server. - Enter a base URL to use this API key variable. tab move · enter next/save · esc back "); } diff --git a/codex-rs/tui/src/app/provider/rpc.rs b/codex-rs/tui/src/app/provider/rpc.rs index b807a6a571..996fddbe86 100644 --- a/codex-rs/tui/src/app/provider/rpc.rs +++ b/codex-rs/tui/src/app/provider/rpc.rs @@ -1,4 +1,4 @@ -//! List providers and select a default from either settings or sign-in. +//! Provider catalog and config writes used by the settings panel and sign-in wizard. use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; @@ -6,11 +6,23 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::MergeStrategy; +use codex_app_server_protocol::ModelProviderConfigWriteParams; +use codex_app_server_protocol::ModelProviderConfigWriteResponse; use codex_app_server_protocol::ModelProviderListParams; use codex_app_server_protocol::ModelProviderListResponse; +use codex_app_server_protocol::ModelProviderSummary; +use codex_app_server_protocol::ProviderSettingsPatch; use codex_app_server_protocol::RequestId; use uuid::Uuid; +pub(crate) const UNCONFIRMED_CREATE: &str = + "Could not confirm the save. Check the provider list before adding it again."; + +pub(crate) enum ProviderWrite { + Saved(ModelProviderSummary), + UnconfirmedCreate, +} + pub(crate) async fn list_providers( handle: &AppServerRequestHandle, ) -> Result { @@ -23,6 +35,38 @@ pub(crate) async fn list_providers( .map_err(request_error) } +pub(crate) async fn write_provider( + handle: &AppServerRequestHandle, + params: ModelProviderConfigWriteParams, +) -> Result { + let create = matches!(¶ms.settings, ProviderSettingsPatch::CustomGateway(gateway) if gateway.provider_id.is_none()); + match handle + .request_typed::( + ClientRequest::ModelProviderConfigWrite { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params, + }, + ) + .await + { + Ok(response) => Ok(ProviderWrite::Saved(response.provider)), + // A lost reply or a failed read-back can follow a committed write. + Err(err) + if create + && match &err { + TypedRequestError::Server { source, .. } => source.code == -32603, + TypedRequestError::Transport { .. } | TypedRequestError::Deserialize { .. } => { + true + } + } => + { + Ok(ProviderWrite::UnconfirmedCreate) + } + Err(TypedRequestError::Server { source, .. }) => Err(source.message), + Err(err) => Err(err.to_string()), + } +} + pub(crate) async fn set_default_provider( handle: &AppServerRequestHandle, id: &str, diff --git a/codex-rs/tui/src/app/provider/write.rs b/codex-rs/tui/src/app/provider/write.rs index 38f381a779..f10b31449d 100644 --- a/codex-rs/tui/src/app/provider/write.rs +++ b/codex-rs/tui/src/app/provider/write.rs @@ -14,14 +14,9 @@ use super::progress; use super::rpc; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; -use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ModelProviderConfigWriteParams; -use codex_app_server_protocol::ModelProviderConfigWriteResponse; -use codex_app_server_protocol::ProviderSettingsPatch; -use codex_app_server_protocol::RequestId; use codex_app_server_protocol::WriteStatus; use tokio::sync::watch; -use uuid::Uuid; const SAVING_VIEW: &str = "model_provider_saving"; @@ -94,29 +89,16 @@ impl Write { provider_changed: &mut bool, ) -> Result { match self { - Self::Settings(settings) => handle - .request_typed::( - ClientRequest::ModelProviderConfigWrite { - request_id: RequestId::String(Uuid::new_v4().to_string()), - params: settings.1.clone(), - }, - ) - .await - .map(|_| SaveNotice::Saved("Saved provider configuration.".into())) - .or_else(|error| { - let create = matches!(&settings.1.settings, - ProviderSettingsPatch::CustomGateway(gateway) if gateway.provider_id.is_none()); - // The server can report an internal error reading back a committed provider. - let uncertain = match &error { - TypedRequestError::Server { source, .. } => source.code == -32603, - TypedRequestError::Transport { .. } | TypedRequestError::Deserialize { .. } => true, - }; - if create && uncertain { - Ok(SaveNotice::UnconfirmedCreate) - } else { - Err(error.to_string()) - } - }), + Self::Settings(settings) => { + rpc::write_provider(handle, settings.1.clone()) + .await + .map(|result| match result { + rpc::ProviderWrite::Saved(_) => { + SaveNotice::Saved("Saved provider configuration.".into()) + } + rpc::ProviderWrite::UnconfirmedCreate => SaveNotice::UnconfirmedCreate, + }) + } Self::Default(id) => { let response = rpc::set_default_provider(handle, id) .await @@ -168,7 +150,7 @@ impl Completion { message } SaveNotice::UnconfirmedCreate => { - let message = "Could not confirm the save. Check the provider list before adding it again.".to_string(); + let message = rpc::UNCONFIRMED_CREATE.to_string(); app.chat_widget.add_error_message(message.clone()); message } diff --git a/codex-rs/tui/src/app/provider_tests.rs b/codex-rs/tui/src/app/provider_tests.rs index c1f7799df7..3688badc5f 100644 --- a/codex-rs/tui/src/app/provider_tests.rs +++ b/codex-rs/tui/src/app/provider_tests.rs @@ -452,7 +452,7 @@ async fn adding_a_custom_provider_saves_to_the_server_and_preserves_the_default( ); let chat = &mut app.chat_widget; insta::assert_snapshot!(render_bottom_popup(chat, /*width*/ 72), @r" - Add custom provider + Create a custom provider or gateway › Name* Team gateway Base URL* https://gateway.example/v1 diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__provider_list.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__provider_list.snap index db97c63221..fc61aedfab 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__provider_list.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__provider_list.snap @@ -6,8 +6,8 @@ expression: "render_bottom_popup(&chat, 80)" Current chat: ollama · Default for new chats: lmstudio -› 1. ○ OpenAI openai - 2. Add custom provider +› 1. ○ OpenAI openai + 2. Create a custom provider or gateway Use /new to start a chat with the selected provider. enter details · space set default · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__saved_default_provider.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__saved_default_provider.snap index bab43e0e3f..c1de0615f1 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__saved_default_provider.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__saved_default_provider.snap @@ -6,9 +6,9 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" Current chat: test-provider · Default for new chats: gateway -› 1. ● Example Gateway gateway - 2. ○ OpenAI openai - 3. Add custom provider +› 1. ● Example Gateway gateway + 2. ○ OpenAI openai + 3. Create a custom provider or gateway /new now uses gateway. Restart the app server and TUI to refresh /model. enter details · esc back diff --git a/codex-rs/tui/src/daemon_startup.rs b/codex-rs/tui/src/daemon_startup.rs index 3ca2317448..6219b898a1 100644 --- a/codex-rs/tui/src/daemon_startup.rs +++ b/codex-rs/tui/src/daemon_startup.rs @@ -16,6 +16,7 @@ use std::collections::BTreeMap; pub(super) const FAILURE_HINT: &str = "To work without the background server, rerun the same command with --no-daemon (including resume or fork and its arguments)."; pub(super) const WSL_DRVFS_EXCLUSION: &str = "a Windows-mounted WSL CODEX_HOME (DrvFS/9p)"; +pub(super) const GATEWAY_SIGN_IN_EXCLUSION: &str = "custom provider or gateway sign-in"; /// Returns whether `codex_home` is on a Windows-mounted WSL filesystem. pub fn uses_wsl_drvfs(codex_home: &std::path::Path) -> bool { diff --git a/codex-rs/tui/src/daemon_startup_tests.rs b/codex-rs/tui/src/daemon_startup_tests.rs index 306e84e0f6..e80c9b19a9 100644 --- a/codex-rs/tui/src/daemon_startup_tests.rs +++ b/codex-rs/tui/src/daemon_startup_tests.rs @@ -414,6 +414,10 @@ fn daemon_exclusion_warning_snapshot() { "Running without the shared background server: {} requires embedded mode.", daemon_startup::WSL_DRVFS_EXCLUSION ), + format!( + "Running without the shared background server: {} requires embedded mode.", + daemon_startup::GATEWAY_SIGN_IN_EXCLUSION + ), ]); let text = cell .transcript_lines(/*width*/ 80) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 14ed755b21..78dcd369c8 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -54,6 +54,7 @@ use crate::motion::MotionMode; use crate::motion::shimmer_text; use crate::onboarding::bedrock::BedrockState; use crate::onboarding::gateway::GatewayStep; +use crate::onboarding::gateway::USE_CUSTOM_PROVIDER; use crate::onboarding::gcp::GcpState; use crate::onboarding::keys; use crate::onboarding::onboarding_screen::KeyboardHandler; @@ -276,6 +277,13 @@ impl KeyboardHandler for AuthModeWidget { } fn handle_paste(&mut self, pasted: String) { + if let Some(wizard) = &mut self.gateway { + if let Some(input) = wizard.active_input_mut() { + input.paste(&pasted); + self.request_frame.schedule_frame(); + } + return; + } let mut state = self.sign_in_state.write().unwrap(); let input = match &mut *state { SignInState::Bedrock(state) => state.active_input_mut(), @@ -363,6 +371,9 @@ impl AuthModeWidget { /// Returns whether the auth flow is currently accepting text input. pub(crate) fn is_text_entry_active(&self) -> bool { + if let Some(wizard) = &self.gateway { + return wizard.is_text_entry_active(); + } self.sign_in_state.read().is_ok_and(|guard| match &*guard { SignInState::ApiKeyEntry(_) => true, SignInState::Bedrock(state) => state.is_text_entry_active(), @@ -376,6 +387,9 @@ impl AuthModeWidget { /// OpenAI API-key entry keeps its existing empty-field quit behavior, while /// Cloud setup fields accept printable input from their first character. pub(crate) fn should_suppress_printable_quit(&self) -> bool { + if let Some(wizard) = &self.gateway { + return wizard.is_text_entry_active(); + } 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(), @@ -653,8 +667,8 @@ impl AuthModeWidget { lines.extend(create_mode_item( idx, option, - "Use a custom gateway", - "Choose a saved gateway", + USE_CUSTOM_PROVIDER, + "Choose a saved configuration or create a new one", )); } } @@ -1271,7 +1285,7 @@ impl WidgetRef for AuthModeWidget { .render(area, buf); } SignInState::GatewaySelected(_) | SignInState::GatewayConfigured => { - Paragraph::new("✓ Custom gateway configured".green()) + Paragraph::new("✓ Custom provider configured".green()) .wrap(Wrap { trim: false }) .render(area, buf); } @@ -1421,6 +1435,7 @@ mod tests { // Embedded startup can refresh cloud config using the host's workload identity. let loader_overrides = LoaderOverrides { ignore_managed_requirements: true, + managed_config_path: Some(codex_home_path.join("managed_config.toml")), ..LoaderOverrides::without_managed_config_for_tests() }; let config = ConfigBuilder::default() diff --git a/codex-rs/tui/src/onboarding/gateway.rs b/codex-rs/tui/src/onboarding/gateway.rs index 6ed70532d1..4ea2c1e19d 100644 --- a/codex-rs/tui/src/onboarding/gateway.rs +++ b/codex-rs/tui/src/onboarding/gateway.rs @@ -1,13 +1,19 @@ -//! Sign in by selecting a saved custom gateway as the default provider. +//! Gateway sign-in uses the settings picker and form. Creating a gateway returns to the +//! picker; only explicitly selecting a saved provider completes sign-in. use super::auth::AuthModeWidget; use super::auth::SignInState; use super::keys; +use super::provider_setup::ProviderInput; +use crate::app::provider::CREATE_CUSTOM_PROVIDER; +use crate::app::provider::Draft; +use crate::app::provider::ProviderForm; use crate::app::provider::provider_item; use crate::app::provider::rpc; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::ListSelectionView; +use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::accept_cancel_hint_line; use crate::key_hint::KeyBindingListExt; @@ -15,6 +21,7 @@ use crate::keymap::ListAction; use crate::keymap::RuntimeKeymap; use crate::render::renderable::Renderable; use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ModelProviderConfigWriteParams; use codex_app_server_protocol::ModelProviderListResponse; use codex_app_server_protocol::ProviderSettings; use codex_app_server_protocol::WriteStatus; @@ -28,28 +35,29 @@ use ratatui::text::Line; use ratatui::widgets::Widget; use tokio::sync::mpsc::unbounded_channel; +pub(super) const USE_CUSTOM_PROVIDER: &str = "Use a custom provider or gateway"; + // Requests stay on the onboarding thread so a redraw can stop polling without losing the reply. pub(super) enum GatewayStep { Loading(LocalBoxFuture<'static, Self>), LoadError(Box), Picking(Box), + Editing { + picker: Box, + form: Box, + }, + Saving(LocalBoxFuture<'static, Self>), Selecting(LocalBoxFuture<'static, Result>), } impl GatewayStep { - fn load(handle: AppServerRequestHandle) -> Self { + fn load(handle: AppServerRequestHandle, notice: Option) -> Self { Self::Loading(Box::pin(async move { match rpc::list_providers(&handle).await { Ok(mut page) if page.can_change_default => { page.data .retain(|p| matches!(p.settings, ProviderSettings::CustomGateway { .. })); - if page.data.is_empty() { - Self::load_error("No custom gateways are configured.".into()) - } else { - Self::Picking(Box::new(Picker::new( - page, /*selected*/ None, /*notice*/ None, - ))) - } + Self::Picking(Box::new(Picker::new(page, /*selected*/ None, notice))) } Ok(_) => Self::load_error( "The default provider is set by configuration policy or a launch override." @@ -63,16 +71,57 @@ impl GatewayStep { fn load_error(message: String) -> Self { let (tx, _) = unbounded_channel(); Self::LoadError(Box::new(ListSelectionView::new( - crate::app::provider::load_error("Use a custom gateway", message), + crate::app::provider::load_error(USE_CUSTOM_PROVIDER, message), AppEventSender::new(tx), RuntimeKeymap::defaults().list, ))) } + fn save( + handle: AppServerRequestHandle, + picker: Box, + mut form: Box, + params: ModelProviderConfigWriteParams, + ) -> Self { + GatewayStep::Saving(Box::pin(async move { + match rpc::write_provider(&handle, params).await { + Ok(rpc::ProviderWrite::Saved(provider)) => { + let mut page = picker.page; + let selected = page.data.len(); + page.data.push(provider); + GatewayStep::Picking(Box::new(Picker::new( + page, + Some(selected), + /*notice*/ None, + ))) + } + Ok(rpc::ProviderWrite::UnconfirmedCreate) => { + GatewayStep::load(handle, Some(rpc::UNCONFIRMED_CREATE.into())) + } + Err(message) => { + form.error = Some(message); + GatewayStep::Editing { picker, form } + } + } + })) + } + + pub(super) fn active_input_mut(&mut self) -> Option<&mut ProviderInput> { + match self { + Self::Editing { form, .. } => form.active_input_mut(), + _ => None, + } + } + + pub(super) fn is_text_entry_active(&self) -> bool { + matches!(self, Self::Editing { form, .. } if form.active_input().is_some()) + } + pub(super) fn render(&self, area: Rect, buf: &mut Buffer) { match self { Self::Loading(_) => Widget::render(Line::from("Loading gateways…".dim()), area, buf), - Self::Selecting(_) => { + Self::Editing { form, .. } => form.render(area, buf), + Self::Saving(_) | Self::Selecting(_) => { Widget::render(Line::from("Saving gateway settings…".dim()), area, buf) } Self::LoadError(list) => list.render(area, buf), @@ -92,23 +141,31 @@ impl Picker { selected: Option, notice: Option, ) -> Self { - let items = page + let mut items: Vec<_> = page .data .iter() .map(|p| provider_item(p, &page.default_provider_id)) .collect(); let keymap = RuntimeKeymap::defaults().list; + let hint = |accept| { + accept_cancel_hint_line( + keymap.primary_hint(ListAction::Accept), + accept, + keymap.primary_hint(ListAction::Cancel), + "to go back", + ) + }; + items.push(SelectionItem { + name: CREATE_CUSTOM_PROVIDER.into(), + footer_hint: Some(hint("to create")), + ..Default::default() + }); // Like the other startup pickers, consume the selection directly before the app event loop exists. let (tx, _) = unbounded_channel(); let list = ListSelectionView::new( SelectionViewParams { - title: Some("Use a custom gateway".into()), - footer_hint: Some(accept_cancel_hint_line( - keymap.primary_hint(ListAction::Accept), - "to use as default", - keymap.primary_hint(ListAction::Cancel), - "to go back", - )), + title: Some(USE_CUSTOM_PROVIDER.into()), + footer_hint: Some(hint("to use as default")), footer_note: notice.map(Into::into), initial_selected_idx: selected, auto_number_shortcuts: false, @@ -120,18 +177,50 @@ impl Picker { ); Self { page, list } } + fn activate(self: Box, index: usize, handle: AppServerRequestHandle) -> GatewayStep { + let Some(provider) = self.page.data.get(index) else { + let form = ProviderForm::new( + Draft::new(/*provider*/ None, &self.page.data), + /*error*/ None, + ); + return GatewayStep::Editing { + picker: self, + form: Box::new(form), + }; + }; + let id = provider.id.clone(); + GatewayStep::Selecting(Box::pin(async move { + let message = match rpc::set_default_provider(&handle, &id).await { + Ok(response) => match response.status { + WriteStatus::Ok => return Ok(id), + WriteStatus::OkOverridden => rpc::override_reason(response), + }, + Err(err) => err.to_string(), + }; + Err(GatewayStep::Picking(Box::new(Picker::new( + self.page, + Some(index), + Some(message), + )))) + })) + } } impl AuthModeWidget { pub(super) fn start_gateway_picker(&mut self) { self.set_error(/*message*/ None); - self.gateway = Some(GatewayStep::load(self.app_server_request_handle.clone())); + self.gateway = Some(GatewayStep::load( + self.app_server_request_handle.clone(), + /*notice*/ None, + )); self.request_frame.schedule_frame(); } pub(super) async fn advance_gateway_request(&mut self) { self.gateway = match &mut self.gateway { - Some(GatewayStep::Loading(request)) => Some(request.await), + Some(GatewayStep::Loading(request) | GatewayStep::Saving(request)) => { + Some(request.await) + } Some(GatewayStep::Selecting(request)) => match request.await { Ok(id) => { *self @@ -143,9 +232,10 @@ impl AuthModeWidget { } Err(picker) => Some(picker), }, - None | Some(GatewayStep::Picking(_) | GatewayStep::LoadError(_)) => { - std::future::pending().await - } + None + | Some( + GatewayStep::Picking(_) | GatewayStep::LoadError(_) | GatewayStep::Editing { .. }, + ) => std::future::pending().await, }; self.request_frame.schedule_frame(); } @@ -160,6 +250,18 @@ impl AuthModeWidget { { None } + GatewayStep::Editing { picker, .. } if keys::CANCEL.is_pressed(key) => { + Some(GatewayStep::Picking(picker)) + } + GatewayStep::Editing { picker, mut form } => { + if let Some(params) = form.input(key) { + self.guard_activation(key); + let handle = self.app_server_request_handle.clone(); + Some(GatewayStep::save(handle, picker, form, params)) + } else { + Some(GatewayStep::Editing { picker, form }) + } + } GatewayStep::LoadError(mut view) => { view.handle_key_event(key); if view.take_last_selected_index().is_some() { @@ -175,22 +277,7 @@ impl AuthModeWidget { picker.list.handle_key_event(key); if let Some(index) = picker.list.take_last_selected_index() { self.guard_activation(key); - let id = picker.page.data[index].id.clone(); - let handle = self.app_server_request_handle.clone(); - Some(GatewayStep::Selecting(Box::pin(async move { - let message = match rpc::set_default_provider(&handle, &id).await { - Ok(response) => match response.status { - WriteStatus::Ok => return Ok(id), - WriteStatus::OkOverridden => rpc::override_reason(response), - }, - Err(err) => err.to_string(), - }; - Err(GatewayStep::Picking(Box::new(Picker::new( - picker.page, - Some(index), - Some(message), - )))) - }))) + Some(picker.activate(index, self.app_server_request_handle.clone())) } else { Some(GatewayStep::Picking(picker)) } diff --git a/codex-rs/tui/src/onboarding/gateway_tests.rs b/codex-rs/tui/src/onboarding/gateway_tests.rs index 52a7eae606..d37ea368d7 100644 --- a/codex-rs/tui/src/onboarding/gateway_tests.rs +++ b/codex-rs/tui/src/onboarding/gateway_tests.rs @@ -1,8 +1,14 @@ #![cfg(test)] use super::*; +use crate::app::provider::rpc::list_providers; +use crate::app::provider::rpc::write_provider; +use codex_app_server_protocol::CustomGatewaySettingsParams; +use codex_app_server_protocol::ProviderSettingsPatch; use crossterm::event::KeyCode; +use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; +use serde_json::json; fn screen(widget: &AuthModeWidget, height: u16) -> String { let area = Rect::new(0, 0, 72, height); @@ -35,13 +41,18 @@ async fn select_a_saved_gateway_during_sign_in() -> color_eyre::Result<()> { insta::assert_snapshot!("gateway_sign_in_menu", screen(&widget, /*height*/ 8)); widget.handle_key_event(KeyCode::Char('4').into()); widget.advance_gateway_request().await; - insta::assert_snapshot!("gateway_load_error", screen(&widget, /*height*/ 8)); + insta::assert_snapshot!("gateway_empty_picker", screen(&widget, /*height*/ 8)); widget.handle_key_event(KeyCode::Esc.into()); - widget.handle_key_event(KeyEvent { - kind: KeyEventKind::Release, - ..KeyCode::Char('4').into() - }); + let managed = home.path().join("managed_config.toml"); + std::fs::write(&managed, "model_provider = 'openai'")?; + widget.handle_key_event(KeyCode::Char('4').into()); + widget.advance_gateway_request().await; + let error = screen(&widget, /*height*/ 8); + assert!(error.contains("configuration policy")); + insta::assert_snapshot!("gateway_load_error", error); + widget.handle_key_event(KeyCode::Esc.into()); + std::fs::remove_file(managed)?; std::fs::write( home.path().join("config.toml"), r#" @@ -57,12 +68,118 @@ wire_api = "responses" widget.handle_key_event(KeyCode::Enter.into()); widget.advance_gateway_request().await; assert!(matches!(widget.get_step_state(), StepState::Complete)); + insta::assert_snapshot!(screen(&widget, /*height*/ 4), @"✓ Custom provider configured"); let saved: toml::Value = toml::from_str(&std::fs::read_to_string(home.path().join("config.toml"))?)?; assert_eq!(saved["model_provider"].as_str(), Some("office")); Ok(()) } +#[tokio::test] +async fn create_highlights_the_saved_gateway_until_explicitly_selected() -> color_eyre::Result<()> { + let (mut widget, home) = super::tests::widget_forced_chatgpt().await; + widget.auth_config.forced_login_method = None; + widget.bedrock_setup_enabled = true; + widget.gcp_setup_enabled = true; + widget.gateway_setup_enabled = true; + write_provider( + &widget.app_server_request_handle, + codex_app_server_protocol::ModelProviderConfigWriteParams { + settings: ProviderSettingsPatch::CustomGateway(CustomGatewaySettingsParams { + name: Some("Office".into()), + base_url: Some(Some("https://office.example.test/v1".into())), + ..Default::default() + }), + }, + ) + .await + .map_err(color_eyre::Report::msg)?; + widget.handle_key_event(KeyCode::Char('3').into()); + widget.handle_key_event(KeyCode::Char('4').into()); + widget.advance_gateway_request().await; + widget.handle_key_event(KeyCode::Down.into()); + widget.handle_key_event(KeyCode::Enter.into()); + insta::assert_snapshot!(screen(&widget, /*height*/ 12), @r" + Create a custom provider or gateway + + › Name* Team gateway + Base URL* https://gateway.example/v1 + API key variable $TEAM_API_KEY + Save + + * Required + Enter the environment variable name on the app server. + tab move · enter next/save · esc back + "); + widget.handle_paste("Team gateway".into()); + widget.handle_key_event(KeyCode::Down.into()); + widget.handle_paste("https://team.example.test/v1?".into()); + widget.handle_key_event(KeyCode::Down.into()); + widget.handle_paste("TEAM_KEY-".into()); + widget.handle_key_event(KeyCode::Down.into()); + insta::assert_snapshot!("gateway_save_focused_short", screen(&widget, /*height*/ 4)); + widget.handle_key_event(KeyCode::Enter.into()); + insta::assert_snapshot!("gateway_save_error_short", screen(&widget, /*height*/ 4)); + widget.handle_key_event(KeyCode::Up.into()); + widget.handle_key_event(KeyCode::Backspace.into()); + widget.handle_key_event(KeyCode::Down.into()); + widget.handle_key_event(KeyCode::Enter.into()); + widget.advance_gateway_request().await; + insta::assert_snapshot!("gateway_validation_retry", screen(&widget, /*height*/ 8)); + + // Correct the invalid URL and keep the other fields. + widget.handle_key_event(KeyCode::Up.into()); + widget.handle_key_event(KeyCode::Up.into()); + widget.handle_key_event(KeyCode::Backspace.into()); + widget.handle_key_event(KeyCode::Down.into()); + widget.handle_key_event(KeyCode::Down.into()); + widget.handle_key_event(KeyCode::Enter.into()); + widget.advance_gateway_request().await; + assert_eq!(widget.error_message(), None); + widget.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::NONE, + KeyEventKind::Repeat, + )); + assert!(matches!(widget.get_step_state(), StepState::InProgress)); + insta::assert_snapshot!("gateway_created_picker", screen(&widget, /*height*/ 8)); + let page = list_providers(&widget.app_server_request_handle) + .await + .map_err(color_eyre::Report::msg)?; + assert_eq!(page.default_provider_id, "openai"); + + widget.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::NONE, + KeyEventKind::Release, + )); + widget.handle_key_event(KeyCode::Enter.into()); + widget.advance_gateway_request().await; + assert!(matches!(widget.get_step_state(), StepState::Complete)); + let saved: serde_json::Value = + toml::from_str(&std::fs::read_to_string(home.path().join("config.toml"))?)?; + assert_eq!( + saved, + json!({ + "model_provider": "team-gateway", + "model_providers": { + "office": { + "name": "Office", + "base_url": "https://office.example.test/v1", + "wire_api": "responses", + }, + "team-gateway": { + "name": "Team gateway", + "base_url": "https://team.example.test/v1", + "env_key": "TEAM_KEY", + "wire_api": "responses", + }, + }, + }) + ); + Ok(()) +} + #[tokio::test] async fn gateway_oauth_displays_the_browser_link_and_retry_action() { let (widget, _home) = super::tests::widget_forced_chatgpt().await; diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_created_picker.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_created_picker.snap new file mode 100644 index 0000000000..e52578883c --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_created_picker.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/onboarding/gateway_tests.rs +expression: screen(&widget) +--- + Use a custom provider or gateway + + + ○ Office office +› ○ Team gateway team-gateway + Create a custom provider or gateway + + Press enter to use as default or esc to go back diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_empty_picker.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_empty_picker.snap new file mode 100644 index 0000000000..443aa6ec86 --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_empty_picker.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/onboarding/gateway_tests.rs +expression: screen(&widget) +--- + Use a custom provider or gateway + + +› Create a custom provider or gateway + + + + Press enter to create or esc to go back diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_load_error.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_load_error.snap index a47f391cb6..4ad95a7475 100644 --- a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_load_error.snap +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_load_error.snap @@ -1,9 +1,10 @@ --- source: tui/src/onboarding/gateway_tests.rs -expression: "screen(&widget, 8)" +expression: error --- - Use a custom gateway - No custom gateways are configured. + Use a custom provider or gateway + The default provider is set by configuration policy or a launch + override. › 1. Back diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_save_error_short.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_save_error_short.snap new file mode 100644 index 0000000000..87960f53fd --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_save_error_short.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/onboarding/gateway_tests.rs +expression: "screen(&widget, 4)" +--- +› Save +Enter an environment variable name such as TEAM_API_KEY. + +* Required diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_save_focused_short.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_save_focused_short.snap new file mode 100644 index 0000000000..5265b81bc3 --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_save_focused_short.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/onboarding/gateway_tests.rs +expression: "screen(&widget, 4)" +--- +› Save + +* Required +Enter the environment variable name on the app server. diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_saved_picker.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_saved_picker.snap index 26cdd9aa58..d358d06d48 100644 --- a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_saved_picker.snap +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_saved_picker.snap @@ -2,11 +2,11 @@ source: tui/src/onboarding/gateway_tests.rs expression: screen(&widget) --- - Use a custom gateway + Use a custom provider or gateway -› ○ Office office - +› ○ Office office + Create a custom provider or gateway Press enter to use as default or esc to go back diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_sign_in_menu.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_sign_in_menu.snap index 8fc332b81b..dcb35edbd7 100644 --- a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_sign_in_menu.snap +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_sign_in_menu.snap @@ -7,6 +7,6 @@ expression: terminal.backend().to_string() > 1. Use an OpenAI API key 2. Use Amazon Bedrock 3. Use Google Cloud Vertex AI - 4. Use a custom gateway + 4. Use a custom provider or gateway Press enter to continue Press esc to go back diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_validation_retry.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_validation_retry.snap new file mode 100644 index 0000000000..bce973f098 --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__gateway_tests__gateway_validation_retry.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/onboarding/gateway_tests.rs +expression: "screen(&widget, 8)" +--- + Base URL* https://team.example.test/v1? + API key variable $TEAM_KEY +› Save +Enter an HTTP(S) base URL without credentials, a query, or a fragment. + +* Required +Enter the environment variable name on the app server. +tab move · enter next/save · esc back diff --git a/codex-rs/tui/src/snapshots/codex_tui__tests__daemon_startup_tests__daemon_exclusion_warning.snap b/codex-rs/tui/src/snapshots/codex_tui__tests__daemon_startup_tests__daemon_exclusion_warning.snap index 6e3a97b527..7c691b56ac 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__tests__daemon_startup_tests__daemon_exclusion_warning.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__tests__daemon_startup_tests__daemon_exclusion_warning.snap @@ -6,3 +6,5 @@ expression: text embedded mode. ⚠ Running without the shared background server: a Windows-mounted WSL CODEX_HOME (DrvFS/9p) requires embedded mode. +⚠ Running without the shared background server: custom provider or gateway sign- + in requires embedded mode. diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index c247a8ea61..736c9d296d 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -602,7 +602,7 @@ pub(super) async fn run_main_inner( daemon_exclusion = Some(if config.features.enabled(Feature::GcpSetupWizard) { "GCP sign-in" } else { - "custom gateway sign-in" + daemon_startup::GATEWAY_SIGN_IN_EXCLUSION }); app_server_target = AppServerTarget::Embedded; } @@ -822,7 +822,7 @@ pub(super) async fn run_main_inner( } } Some("GCP sign-in") => "gcp_sign_in", - Some("custom gateway sign-in") => "gateway_sign_in", + Some(daemon_startup::GATEWAY_SIGN_IN_EXCLUSION) => "gateway_sign_in", Some(daemon_startup::WSL_DRVFS_EXCLUSION) => "wsl_drvfs", Some("this Windows launcher") => "windows_detach_restricted", Some("daemon feature settings") => "daemon_compatibility", diff --git a/codex-rs/tui/tests/suite/provider_defaults.rs b/codex-rs/tui/tests/suite/provider_defaults.rs index 5c6cfb555b..f7b676f33e 100644 --- a/codex-rs/tui/tests/suite/provider_defaults.rs +++ b/codex-rs/tui/tests/suite/provider_defaults.rs @@ -189,7 +189,7 @@ requires_openai_auth = true .with_context(|| terminal.screen_contents())?; // Startup drains input after its first draw. Retry the menu shortcut until it is accepted. let deadline = std::time::Instant::now() + Duration::from_secs(/*secs*/ 30); - while !terminal.screen_contains("Use a custom gateway") { + while !terminal.screen_contains("Use a custom provider or gateway") { ensure!( std::time::Instant::now() < deadline, "gateway option missing: {}",