diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0d062fb6da..f2f7d48ca3 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -254,6 +254,7 @@ mod permission_shortcuts; mod pets; mod platform_actions; mod plugin_mentions; +pub(crate) mod provider; mod rate_limit_refresh; mod realtime_delivery; mod realtime_settings; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 10ff08ee6f..010a712dc3 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -68,6 +68,7 @@ impl App { | AppEvent::TerminalTitleSetupPreview { .. } | AppEvent::TerminalTitleSetupCancelled | AppEvent::OpenConfigEditor(_) + | AppEvent::Provider(_) | AppEvent::SyntaxThemeSelected { .. } | AppEvent::SyntaxThemePreviewed | AppEvent::OpenKeymapActionMenu { .. } @@ -3635,6 +3636,7 @@ impl App { self.chat_widget.cancel_terminal_title_setup(); self.chat_widget.refresh_config_panel(/*notice*/ None); } + AppEvent::Provider(event) => super::provider::handle(self, tui, app_server, event), AppEvent::OpenConfigEditor(handoff) => { self.cancel_pending_key_chord(); if let Err(err) = tui.discard_pending_input_before_interactive_screen() { diff --git a/codex-rs/tui/src/app/provider.rs b/codex-rs/tui/src/app/provider.rs new file mode 100644 index 0000000000..84b067fd05 --- /dev/null +++ b/codex-rs/tui/src/app/provider.rs @@ -0,0 +1,230 @@ +//! Browse the connected server's provider catalog without changing the active chat. + +use super::App; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::backend_id::BackendId; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::pager_overlay::Overlay; +use crate::tui::Tui; +use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ModelProviderListParams; +use codex_app_server_protocol::ModelProviderListResponse; +use codex_app_server_protocol::ModelProviderSummary; +use codex_app_server_protocol::ProviderSettings; +use codex_app_server_protocol::RequestId; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::sync::Arc; +use tokio::sync::watch; +use uuid::Uuid; + +mod fields; +use fields::Field; +use fields::provider_fields; + +const LOADING_VIEW: &str = "model_provider_loading"; +pub(super) const LIST_VIEW: &str = "model_provider_list"; + +#[derive(Debug)] +pub(crate) enum Event { + Load(watch::Receiver<()>), + Loaded { + backend: BackendId, + dismissed: watch::Receiver<()>, + result: Result, + }, + Details { + backend: BackendId, + provider: Arc, + }, +} + +fn back() -> SelectionItem { + SelectionItem { + name: "Back".into(), + dismiss_on_select: true, + ..Default::default() + } +} + +pub(crate) fn loading() -> (SelectionViewParams, Event) { + // Each popup owns its sender. Dismissal, widget replacement, and reopening + // invalidate only that popup's request, independent of initial thread attachment. + let (dismiss, dismissed) = watch::channel(()); + let view = SelectionViewParams { + view_id: Some(LOADING_VIEW), + title: Some("Loading model providers…".into()), + items: vec![back()], + allow_input_when_disconnected: true, + on_cancel: Some(Box::new(move |_| { + dismiss.send_replace(()); + })), + ..Default::default() + }; + (view, Event::Load(dismissed)) +} + +pub(super) fn handle(app: &mut App, tui: &mut Tui, server: &AppServerSession, event: Event) { + let backend = server.backend_id(); + match event { + Event::Load(mut dismissed) => { + let tx = app.app_event_tx.clone(); + let handle = server.request_handle(); + let offline = app.reconnect.offline; + tokio::spawn(async move { + let request = ClientRequest::ModelProviderList { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: ModelProviderListParams {}, + }; + let result = if offline { + Err("Reconnect to the app server to browse model providers.".into()) + } else { + tokio::select! { + _ = dismissed.changed() => return, + result = handle.request_typed(request) => result.map_err(request_error), + } + }; + tx.send(AppEvent::Provider(Event::Loaded { + backend, + dismissed, + result, + })); + }); + } + Event::Loaded { + backend: origin, + dismissed, + result, + } => { + if origin != backend || !matches!(dismissed.has_changed(), Ok(false)) { + return; + } + let chat = &mut app.chat_widget; + let view = match result { + Ok(page) => list( + chat.thread_id() + .map(|_| chat.config_ref().model_provider_id.as_str()), + backend, + page, + ), + Err(error) => SelectionViewParams { + title: Some("Model providers".into()), + subtitle: Some(error), + items: vec![back()], + allow_input_when_disconnected: true, + ..Default::default() + }, + }; + chat.replace_selection_view_if_present(LOADING_VIEW, view); + } + Event::Details { + backend: origin, + provider, + } => { + if origin == backend + && app.overlay.is_none() + && app.chat_widget.active_view_is(LIST_VIEW) + { + let _ = tui.enter_alt_screen(); + app.overlay = Some(Overlay::new_static_with_lines( + details(&provider), + "Model provider details".into(), + app.keymap.pager.clone(), + )); + tui.frame_requester().schedule_frame(); + } + } + } +} + +/// Refresh a list opened before the first chat acquired a server-side provider. +pub(crate) fn refresh_list_if_present(chat: &mut crate::chatwidget::ChatWidget) -> Option { + if chat.no_modal_or_popup_active() { + return None; + } + let (view, event) = loading(); + chat.replace_selection_view_if_present(LIST_VIEW, view) + .then_some(event) +} + +fn request_error(err: TypedRequestError) -> String { + if let TypedRequestError::Server { method, source } = &err + && (source.code == -32601 + || (source.code == -32600 + && source + .message + .starts_with(&format!("Invalid request: unknown variant `{method}`")))) + { + return "Update the connected app server to browse model providers.".into(); + } + format!("Could not load model providers: {err}") +} + +fn list( + current: Option<&str>, + backend: BackendId, + page: ModelProviderListResponse, +) -> SelectionViewParams { + let current_label = current.unwrap_or("No active chat"); + let items = page + .data + .into_iter() + .map(|provider| { + let provider = Arc::new(provider); + SelectionItem { + name: provider.name.clone(), + is_current: current == Some(provider.id.as_str()), + description: Some(provider.id.clone()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::Provider(Event::Details { + backend, + provider: Arc::clone(&provider), + })); + })], + ..Default::default() + } + }) + .collect(); + SelectionViewParams { + view_id: Some(LIST_VIEW), + title: Some("Model providers".into()), + subtitle: Some(format!( + "Current chat: {current_label} · Default for new chats: {}", + page.default_provider_id + )), + items, + allow_input_when_disconnected: true, + ..Default::default() + } +} + +fn details(provider: &ModelProviderSummary) -> Vec> { + let mut lines = vec![Line::from(provider.name.clone().bold()), Line::default()]; + let mut field = |label: &str, value: &str| { + lines.push(Line::from(vec![ + format!("{label} ").bold(), + value.to_owned().into(), + ])); + }; + field("Provider ID", &provider.id); + if matches!(provider.settings, ProviderSettings::OpenAi) { + field("Type", "Built-in OpenAI provider"); + } + for (kind, value) in provider_fields(provider) { + let absent = match kind { + Field::Url | Field::ManagedUrl => "Not reported", + Field::ExternalAuth => "Managed externally", + Field::Variable => "None", + _ => "Not set", + }; + field(kind.label(), value.unwrap_or(absent)); + } + lines +} + +#[cfg(test)] +#[path = "provider_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/provider/fields.rs b/codex-rs/tui/src/app/provider/fields.rs new file mode 100644 index 0000000000..afe45ee89c --- /dev/null +++ b/codex-rs/tui/src/app/provider/fields.rs @@ -0,0 +1,83 @@ +//! Fields reported by the server for each provider, including values it manages externally. + +use codex_app_server_protocol::GatewayAuth; +use codex_app_server_protocol::ModelProviderSummary; +use codex_app_server_protocol::ProviderSettings; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(super) enum Field { + Name, + Url, + ManagedUrl, + Variable, + ExternalAuth, + Profile, + Region, + Project, + Location, + Credentials, +} + +impl Field { + pub(super) fn label(self) -> &'static str { + match self { + Self::Name => "Name", + Self::Url | Self::ManagedUrl => "Base URL", + Self::Variable => "API key variable", + Self::ExternalAuth => "External authentication", + Self::Profile => "AWS profile", + Self::Region => "AWS region", + Self::Project => "Project ID", + Self::Location => "Location", + Self::Credentials => "Credential file (server)", + } + } +} + +pub(super) fn provider_fields(provider: &ModelProviderSummary) -> Vec<(Field, Option<&str>)> { + let url = provider.base_url.as_deref(); + let mut fields = match &provider.settings { + ProviderSettings::OpenAi => Vec::new(), + ProviderSettings::CustomGateway { auth } => { + let auth_field = match auth { + GatewayAuth::None => (Field::Variable, None), + GatewayAuth::EnvironmentVariable { env_key } => { + (Field::Variable, Some(env_key.as_str())) + } + GatewayAuth::ExternallyConfigured { env_key } => { + (Field::ExternalAuth, env_key.as_deref()) + } + }; + let endpoint = if auth_field.0 == Field::ExternalAuth && url.is_none() { + Field::ManagedUrl + } else { + Field::Url + }; + return vec![ + (Field::Name, Some(provider.name.as_str())), + (endpoint, url), + auth_field, + ]; + } + ProviderSettings::Bedrock { profile, region } => vec![ + ( + Field::Profile, + profile.as_deref().filter(|p| !p.trim().is_empty()), + ), + (Field::Region, region.as_deref()), + ], + ProviderSettings::Gcp { + project_id, + location, + credential_file, + } => vec![ + (Field::Project, project_id.as_deref()), + (Field::Location, location.as_deref()), + (Field::Credentials, credential_file.as_deref()), + ], + }; + if let Some(url) = url { + fields.push((Field::ManagedUrl, Some(url))); + } + fields +} diff --git a/codex-rs/tui/src/app/provider_tests.rs b/codex-rs/tui/src/app/provider_tests.rs new file mode 100644 index 0000000000..40f8087219 --- /dev/null +++ b/codex-rs/tui/src/app/provider_tests.rs @@ -0,0 +1,204 @@ +//! Provider browsing through loading, startup attachment, nested views, and dismissal. +#![cfg(test)] + +use super::*; +use crate::app::tests::make_test_app_with_channels; +use crate::app::tests::test_thread_session; +use crate::chatwidget::tests::render_bottom_popup; +use crate::config_panel::ConfigEditor; +use crate::config_panel::ConfigEditorHandoff; +use crate::config_panel::ResumeCurrentAvailability; +use crate::tui::TuiEvent; +use crate::tui::test_support::make_test_tui; +use codex_protocol::ThreadId; +use color_eyre::eyre::Result; +use crossterm::event::KeyCode; +use pretty_assertions::assert_eq; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use tokio::sync::mpsc::UnboundedReceiver; + +async fn next_provider_event(rx: &mut UnboundedReceiver) -> Event { + loop { + if let AppEvent::Provider(event) = rx.recv().await.expect("open event channel") { + return event; + } + } +} + +#[tokio::test] +async fn loading_survives_initial_attachment_and_refreshes_under_an_overlay() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let mut tui = make_test_tui()?; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + app.chat_widget.open_config_panel(); + let handoff = ConfigEditorHandoff::new( + ConfigEditor::ModelProviders, + crate::key_hint::plain(KeyCode::Enter), + ); + handoff.mark_activation_inactive(); + app.chat_widget + .open_config_editor(handoff, ResumeCurrentAvailability::Available); + assert!(app.chat_widget.thread_id().is_none()); + handle( + &mut app, + &mut tui, + &server, + next_provider_event(&mut events).await, + ); + let loaded = next_provider_event(&mut events).await; + let session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()); + app.chat_widget.handle_thread_session_quiet(session); + app.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("test_overlay"), + items: vec![back()], + ..Default::default() + }); + handle(&mut app, &mut tui, &server, loaded); + assert!(app.chat_widget.active_view_is("test_overlay")); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + assert!(app.chat_widget.active_view_is(LIST_VIEW)); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + handle( + &mut app, + &mut tui, + &server, + next_provider_event(&mut events).await, + ); + assert!(matches!(app.overlay, Some(Overlay::Static(_)))); + app.handle_backtrack_overlay_event( + &mut tui, + &mut server, + TuiEvent::Key(KeyCode::Char('q').into()), + ) + .await?; + assert!(app.overlay.is_none()); + assert!(app.chat_widget.active_view_is(LIST_VIEW)); + + // A late result cannot replace a new loading popup after Back and reopen. + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + let (view, load) = loading(); + app.chat_widget.show_selection_view(view); + handle(&mut app, &mut tui, &server, load); + let stale = next_provider_event(&mut events).await; + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + let (view, _load) = loading(); + app.chat_widget.show_selection_view(view); + handle(&mut app, &mut tui, &server, stale); + assert!(app.chat_widget.active_view_is(LOADING_VIEW)); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn provider_views_show_reported_endpoints_and_ids_outside_the_catalog() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let mut tui = make_test_tui()?; + let server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let provider = ModelProviderSummary { + id: "openai".into(), + name: "OpenAI".into(), + can_edit: false, + base_url: Some( + "https://example.test/organization/deployment/region/service/openai/v1".into(), + ), + settings: ProviderSettings::OpenAi, + }; + let page = ModelProviderListResponse { + data: vec![provider], + default_provider_id: "lmstudio".into(), + can_change_default: true, + }; + app.chat_widget + .show_selection_view(list(Some("ollama"), server.backend_id(), page)); + insta::assert_snapshot!( + "provider_list", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + handle( + &mut app, + &mut tui, + &server, + next_provider_event(&mut events).await, + ); + let Some(Overlay::Static(pager)) = &mut app.overlay else { + panic!("expected provider details pager"); + }; + let mut terminal = Terminal::new(TestBackend::new(/*width*/ 48, /*height*/ 9))?; + terminal.draw(|frame| pager.render(frame.area(), frame.buffer_mut()))?; + let first_page = terminal.backend().to_string(); + pager.handle_event(&mut tui, TuiEvent::Key(KeyCode::PageDown.into()))?; + terminal.draw(|frame| pager.render(frame.area(), frame.buffer_mut()))?; + insta::assert_snapshot!( + "openai_provider_details", + format!("{first_page}\n{}", terminal.backend()) + ); + server.shutdown().await?; + Ok(()) +} + +#[test] +fn unsupported_list_method_explains_server_compatibility() { + for (code, message) in [ + (-32601, "Method not found"), + ( + -32600, + "Invalid request: unknown variant `modelProvider/list`", + ), + ] { + let error = TypedRequestError::Server { + method: "modelProvider/list".into(), + source: codex_app_server_protocol::JSONRPCErrorError { + code, + message: message.into(), + data: None, + }, + }; + assert_eq!( + request_error(error), + "Update the connected app server to browse model providers." + ); + } +} + +#[tokio::test] +async fn list_opened_before_first_attachment_shows_the_attached_provider() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let mut tui = make_test_tui()?; + let server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let (view, event) = loading(); + app.chat_widget.show_selection_view(view); + handle(&mut app, &mut tui, &server, event); + handle( + &mut app, + &mut tui, + &server, + next_provider_event(&mut events).await, + ); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 100) + .contains("Current chat: No active chat") + ); + + let mut session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()); + session.model_provider_id = "ollama".into(); + app.chat_widget.handle_thread_session_quiet(session); + assert!(app.chat_widget.active_view_is(LOADING_VIEW)); + handle( + &mut app, + &mut tui, + &server, + next_provider_event(&mut events).await, + ); + handle( + &mut app, + &mut tui, + &server, + next_provider_event(&mut events).await, + ); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Current chat: ollama")); + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__openai_provider_details.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__openai_provider_details.snap new file mode 100644 index 0000000000..94d50d0ee8 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__openai_provider_details.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/app/provider_tests.rs +expression: "format!(\"{first_page}\\n{}\", terminal.backend())" +--- +"/ Model provider details/ / / / / / / / / / / / " +"OpenAI " +" " +"Provider ID openai " +"Type Built-in OpenAI provider " +"─────────────────────────────────────────── 0% ─" +" ↑/↓ to scroll · pgup/pgdn to page · home/end to" +" q close " +" " + +"/ Model provider details/ / / / / / / / / / / / " +"Type Built-in OpenAI provider " +"Base URL " +"https://example.test/organization/deployment/reg" +"ion/service/openai/v1 " +"───────────────────────────────────────── 100% ─" +" ↑/↓ to scroll · pgup/pgdn to page · home/end to" +" q close " +" " 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 new file mode 100644 index 0000000000..5009654bdc --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__provider__tests__provider_list.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/provider_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Model providers + Current chat: ollama · Default for new chats: lmstudio + + +› 1. OpenAI openai + + enter select · esc back diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0938adbf41..7c536ab862 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1778,6 +1778,8 @@ pub(crate) enum AppEvent { /// Remember the Command Center grouping across launches. PersistAgentsOverviewGrouping(codex_config::types::AgentsOverviewGrouping), + Provider(crate::app::provider::Event), + /// Open an existing editor above the configuration panel. OpenConfigEditor(crate::config_panel::ConfigEditorHandoff), diff --git a/codex-rs/tui/src/chatwidget/config_panel.rs b/codex-rs/tui/src/chatwidget/config_panel.rs index 1fce6907c5..9224388363 100644 --- a/codex-rs/tui/src/chatwidget/config_panel.rs +++ b/codex-rs/tui/src/chatwidget/config_panel.rs @@ -27,6 +27,12 @@ impl ChatWidget { resume_current_availability: crate::config_panel::ResumeCurrentAvailability, ) { match handoff.editor { + ConfigEditor::ModelProviders => { + let (view, event) = crate::app::provider::loading(); + self.bottom_pane.show_selection_view(view); + self.app_event_tx + .send(crate::app_event::AppEvent::Provider(event)); + } ConfigEditor::Theme => self.open_theme_picker(), ConfigEditor::Effects => { let keymap = self.bottom_pane.list_keymap(); diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index ce6c1b604d..3ce1a25743 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -136,6 +136,11 @@ impl ChatWidget { self.current_cwd = Some(session.cwd.to_path_buf()); self.config.cwd = session.cwd.clone(); self.config.model_provider_id = session.model_provider_id.clone(); + if previous_thread_id.is_none() + && let Some(event) = crate::app::provider::refresh_list_if_present(self) + { + self.app_event_tx.send(AppEvent::Provider(event)); + } self.set_daybreak_enabled(self.daybreak_enabled); if connector_scope_changed { self.invalidate_connector_scope(); diff --git a/codex-rs/tui/src/config_panel/mod.rs b/codex-rs/tui/src/config_panel/mod.rs index 7b2bd712c4..393804a573 100644 --- a/codex-rs/tui/src/config_panel/mod.rs +++ b/codex-rs/tui/src/config_panel/mod.rs @@ -58,6 +58,7 @@ pub(crate) enum ConfigEditor { StatusLine, TerminalTitle, Keyboard, + ModelProviders, SessionPickerLayout, ResumeDirectory, CopyOnSelect, @@ -138,6 +139,7 @@ impl ConfigEditorHandoff { impl ConfigEditor { fn value(self, tui: &Tui, status: ConfigPanelStatus) -> ConfigSettingValue { let label = match self { + Self::ModelProviders => "Browse".to_string(), Self::Theme => tui.theme.clone().unwrap_or_else(|| "Automatic".to_string()), Self::SessionPickerLayout => match tui.session_picker_view.unwrap_or_default() { codex_config::types::SessionPickerViewMode::Comfortable => "Comfortable", @@ -407,6 +409,13 @@ fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec { keywords: "cwd folder path fork ask", value: ConfigEditor::ResumeDirectory.value(tui, status), }, + ConfigSetting { + section: ConfigSection::Sessions, + label: "Model providers", + description: "Browse providers configured on the connected app server", + keywords: "provider gateway endpoint model default", + value: ConfigEditor::ModelProviders.value(tui, status), + }, ConfigSetting { section: ConfigSection::TextSelection, label: "Copy on select", diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Sessions.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Sessions.snap index be1c9abf60..80cc9c9dc1 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Sessions.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Sessions.snap @@ -11,7 +11,7 @@ expression: "render(&panel, 140)" › Automatic recaps [ ] Off Prepare recaps while the terminal is unfocused Session picker layout Dense › Choose tighter rows or a roomier list when resuming and forking conversations Resume directory Ask › Choose which working directory to use when the current directory and saved session differ - + Model providers Browse › Browse providers configured on the connected app server