Add model provider browsing to the TUI configuration panel (#59541)

## What changed

Add a Model providers entry under Sessions to browse the connected app server's provider catalog. Show the current chat's provider and the default for new chats, with scrollable details for endpoints, authentication settings, and provider-specific configuration. Browsing does not change the active provider.

Handle loading, dismissal, stale responses, disconnected servers, and unsupported server versions. Refresh an open provider list when the first chat attaches.

## Testing

Add tests and snapshots covering provider lists, scrollable details, initial chat attachment, nested views, stale results after dismissal and reopening, and unsupported server methods.

GitOrigin-RevId: 2deb4e286b2648d9f459e403361e5501db98ca96
This commit is contained in:
alexsong-oai
2026-11-05 01:44:30 +00:00
committed by copyberry
parent b386e2d9e3
commit f1b9421648
12 changed files with 577 additions and 1 deletions
+1
View File
@@ -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;
+2
View File
@@ -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() {
+230
View File
@@ -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<ModelProviderListResponse, String>,
},
Details {
backend: BackendId,
provider: Arc<ModelProviderSummary>,
},
}
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<Event> {
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<Line<'static>> {
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;
+83
View File
@@ -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
}
+204
View File
@@ -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<AppEvent>) -> 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(())
}
@@ -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 "
" "
@@ -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
+2
View File
@@ -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),
@@ -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();
@@ -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();
+9
View File
@@ -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<ConfigSetting> {
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",
@@ -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