From c2cfa2d1767c63a970b55818475b23a289c2d43d Mon Sep 17 00:00:00 2001 From: David Danialy Date: Thu, 22 Oct 2026 11:55:10 -0700 Subject: [PATCH 1/3] feat(tui): offer sign-in for signed-out providers in the model picker The provider page lists providers that advertise a terminal sign-in method but have no models yet below the active ones, greyed out. Entering one starts that provider's login flow. /model opens the picker when only sign-in methods are available. Co-Authored-By: Claude Opus 5.5 --- src/tui/app.rs | 123 ++++++++++++++++++++++++++++++++++++++++++++----- src/tui/ui.rs | 25 ++++++---- 2 files changed, 126 insertions(+), 22 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 26402f5..eb02ab1 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -234,6 +234,14 @@ pub struct ModelSwitch { pub cancelling: bool, } +/// A row on the model dialog's provider page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProviderRow<'a> { + pub name: &'a str, + /// Set for providers without models until the user signs in. + pub login: Option<&'a AuthMethodTerminal>, +} + pub struct ModelDialog { pub query: String, pub selected: usize, @@ -3600,11 +3608,40 @@ impl App { .filter(move |choice| choice.provider == provider) } - /// Providers matching the search on the provider page; empty on the model page. - pub fn selected_model_providers(&self) -> Vec<&str> { + /// Advertised sign-in methods for providers that have no models yet. + fn login_providers(&self) -> Vec> { + let active = self.model_providers(); + self.auth_methods + .iter() + .filter_map(|method| { + let name = crate::protocols::acp::terminal_auth_method_specs() + .iter() + .find(|spec| spec.method_id == method.method_id.0.as_ref())? + .provider + .as_str(); + (!active.contains(&name)).then_some(ProviderRow { + name, + login: Some(method), + }) + }) + .collect() + } + + /// Providers matching the search on the provider page, with signed-out + /// providers after the active ones; empty on the model page. + pub fn selected_model_providers(&self) -> Vec> { match &self.model_dialog { Some(dialog) if dialog.provider.is_none() => { - rank_by_name(self.model_providers(), &dialog.query, |provider| provider) + let active = self + .model_providers() + .into_iter() + .map(|name| ProviderRow { name, login: None }) + .collect(); + let mut rows = rank_by_name(active, &dialog.query, |row| row.name); + rows.extend(rank_by_name(self.login_providers(), &dialog.query, |row| { + row.name + })); + rows } _ => Vec::new(), } @@ -3734,12 +3771,21 @@ impl App { .as_ref() .is_some_and(|dialog| dialog.provider.is_none()) { - let provider = self - .selected_model_providers() - .get(selected) - .map(|provider| (*provider).to_owned()); - if let Some(provider) = provider { - self.open_model_provider(provider); + let row = self.selected_model_providers().get(selected).copied(); + match row { + Some(ProviderRow { + login: Some(method), + .. + }) => { + let method = method.clone(); + self.model_dialog = None; + return Action::Login(method); + } + Some(ProviderRow { name, login: None }) => { + let provider = name.to_owned(); + self.open_model_provider(provider); + } + None => {} } return Action::None; } @@ -4699,7 +4745,7 @@ impl App { } }, Parsed::Model { query: None } => { - if self.model_choices.is_empty() { + if self.model_choices.is_empty() && self.auth_methods.is_empty() { self.toast("no models are available"); } else { self.open_model_dialog(); @@ -9051,6 +9097,59 @@ mod tests { ] } + fn provider_names(app: &App) -> Vec<&str> { + app.selected_model_providers() + .into_iter() + .map(|row| row.name) + .collect() + } + + #[test] + fn model_dialog_lists_signed_out_providers_after_active_ones_and_logs_in() { + let mut app = app(); + app.set_model_choices(vec![model_choice("openai-subscription", "gpt-5.4")]); + app.auth_methods = vec![ + AuthMethodTerminal::new("openai", "Sign in with ChatGPT"), + AuthMethodTerminal::new("openrouter", "Sign in with OpenRouter"), + AuthMethodTerminal::new("anthropic", "Sign in with Anthropic"), + ]; + app.open_model_dialog(); + + let rows = app.selected_model_providers(); + assert_eq!( + rows.iter().map(|row| row.name).collect::>(), + ["openai-subscription", "openrouter", "anthropic"] + ); + assert_eq!( + rows.iter() + .map(|row| row.login.is_some()) + .collect::>(), + [false, true, true] + ); + + for character in "anthr".chars() { + app.handle_key(press(KeyCode::Char(character))); + } + assert_eq!(provider_names(&app), ["anthropic"]); + let Action::Login(method) = app.handle_key(press(KeyCode::Enter)) else { + panic!("expected login"); + }; + assert_eq!(method.method_id.0.as_ref(), "anthropic"); + assert!(app.model_dialog.is_none()); + } + + #[test] + fn model_command_opens_the_provider_page_with_only_sign_in_methods() { + let mut app = app(); + app.auth_methods = vec![AuthMethodTerminal::new( + "anthropic", + "Sign in with Anthropic", + )]; + app.editor.insert_str("/model"); + app.handle_key(press(KeyCode::Enter)); + assert_eq!(provider_names(&app), ["anthropic"]); + } + #[test] fn sessions_command_defers_catalog_work_to_the_event_loop() { let mut app = app(); @@ -9361,7 +9460,7 @@ mod tests { assert_eq!(dialog.provider, None); assert_eq!(dialog.selected, 0); assert_eq!( - app.selected_model_providers(), + provider_names(&app), ["openai-subscription", "anthropic", "openrouter"] ); assert!(app.selected_model_choices().is_empty()); @@ -9370,7 +9469,7 @@ mod tests { for character in "openr".chars() { app.handle_key(press(KeyCode::Char(character))); } - assert_eq!(app.selected_model_providers(), ["openrouter"]); + assert_eq!(provider_names(&app), ["openrouter"]); app.handle_key(press(KeyCode::Enter)); let dialog = app.model_dialog.as_ref().expect("model dialog"); assert_eq!(dialog.provider.as_deref(), Some("openrouter")); diff --git a/src/tui/ui.rs b/src/tui/ui.rs index df250f0..bd7e793 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -832,26 +832,30 @@ fn draw_model_dialog(frame: &mut Frame<'_>, app: &App, dialog: &ModelDialog) { width, height, ); - let (labels, title, enter, esc) = match &dialog.provider { + // Rows flagged `true` are signed-out providers; entering one starts login. + let (labels, title, esc) = match &dialog.provider { None => ( app.selected_model_providers() .into_iter() - .map(str::to_owned) + .map(|row| (row.name.to_owned(), row.login.is_some())) .collect::>(), " provider ".to_owned(), - "open", "close", ), Some(provider) => ( app.selected_model_choices() .into_iter() - .map(|choice| choice.model.clone()) + .map(|choice| (choice.model.clone(), false)) .collect(), format!(" {provider} · model "), - "select", "back", ), }; + let enter = match (&dialog.provider, labels.get(dialog.selected)) { + (Some(_), _) => "select", + (None, Some((_, true))) => "sign in", + (None, _) => "open", + }; let footer = format!( "tab defaults [{}] · enter {enter} · esc {esc}", if dialog.save_defaults { "x" } else { " " } @@ -871,13 +875,14 @@ fn draw_model_dialog(frame: &mut Frame<'_>, app: &App, dialog: &ModelDialog) { let lines = labels[viewport.clone()] .iter() .zip(viewport) - .map(|(label, index)| { + .map(|((label, signed_out), index)| { let selected = index == dialog.selected; let marker = if selected { "› " } else { " " }; - let style = if selected { - theme::accent() - } else { - theme::text() + let style = match (selected, *signed_out) { + (true, false) => theme::accent(), + (true, true) => theme::dim(), + (false, false) => theme::text(), + (false, true) => theme::faint(), }; Line::from(Span::styled(format!("{marker}{label}"), style)) }) From 93b3d6d6f34d114931a69a3f5da382a8afe0f9a7 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Thu, 22 Oct 2026 13:07:44 -0700 Subject: [PATCH 2/3] fix(tui): drive provider sign-in from agent-advertised state The agent tags each terminal auth method with its provider under the `kit/provider` metadata key and rebuilds the session's model catalog when the set of credentialed providers changes, so the post-login refresh advertises the new provider's models. The TUI treats a provider as signed in when the catalog has a group for it, even if model discovery returned nothing, and offers sign-in only once a catalog is known. Picker sign-in waits for the current turn, keeps the selection highlighted, and reopens the provider's models with the same defaults choice after a successful login. /model no longer opens an empty picker. Co-Authored-By: Claude Opus 5.5 --- docs/user/tui-and-sessions.md | 2 +- src/protocols/acp.rs | 3 + src/protocols/acp/v2.rs | 63 ++++++++- src/provider/adapter.rs | 9 ++ src/tui/app.rs | 233 +++++++++++++++++++++++++++------- src/tui/mod.rs | 37 ++++-- src/tui/ui.rs | 51 ++++++-- 7 files changed, 323 insertions(+), 75 deletions(-) diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 45d597d..45cada7 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -210,7 +210,7 @@ The TUI handles `/new`, `/resume`, `/sessions`, `/close`, `/model`, `/effort`, ` The other commands listed above are available only while the session is idle. `/agents` toggles the agent roster without starting a model turn. `/new` closes the current session and starts a fresh persisted session. It clears the visible transcript but does not delete or alter the previous session, which remains resumable by its ID. Text following `/new` becomes the new session's first prompt. `/resume ` closes the current session, resumes the requested durable session, and replays its transcript; selecting the already-active ID is a no-op. `/sessions` opens a visible newest-first selector for the same workspace. Up and Down move, Enter uses the existing resume flow, `R` opens an inline rename field, and Esc cancels renaming or closes the dialog. Submit an empty rename and confirm to clear the custom name. After a save, the picker remains open on the selected session and refreshes its displayed name. `/close` closes the current session and exits the TUI. -`/model` opens the model selector on a provider list; Enter on a provider shows that provider's models. Typing searches only the current page, and the search clears when you move between pages. Esc returns from the model list to the provider list, and closes the selector from the provider list. `/effort` opens the advertised ACP reasoning-effort selector; `/effort default|low|medium|high` selects directly. In either dialog, Tab toggles saving the selection to `~/.kit/config.toml`, Enter selects, and Esc closes. Saving `default` removes top-level `reasoning_effort`; other values update it without replacing unrelated TOML. New sessions start from the resolved CLI/TOML default. Durable sessions remember their reasoning-effort selection, including an explicit provider `default`, and restore it on load or resume even if the process default has changed. Older transcripts without a recorded effort continue to use the process default. Forks inherit the source session’s current effort. Saving to TOML changes the default for future sessions; it is not required to retain the effort of the current session. Model selection still starts from the resolved CLI/TOML default on reload. Reasoning-effort records require a Kit version that understands transcript schema 6; older versions reject these records rather than silently discard the saved selection. +`/model` opens the model selector on a provider list; Enter on a provider shows that provider's models. Once the session is open, providers that Kit can sign in to but that have no credentials yet are listed greyed out below the signed-in ones. Enter on one starts its terminal sign-in, the same flow as `/login `, and is available only while the session is idle; after a successful sign-in, the selector reopens on that provider's models with the same Tab setting. Typing searches only the current page, and the search clears when you move between pages. Esc returns from the model list to the provider list, and closes the selector from the provider list. `/effort` opens the advertised ACP reasoning-effort selector; `/effort default|low|medium|high` selects directly. In either dialog, Tab toggles saving the selection to `~/.kit/config.toml`, Enter selects, and Esc closes. Saving `default` removes top-level `reasoning_effort`; other values update it without replacing unrelated TOML. New sessions start from the resolved CLI/TOML default. Durable sessions remember their reasoning-effort selection, including an explicit provider `default`, and restore it on load or resume even if the process default has changed. Older transcripts without a recorded effort continue to use the process default. Forks inherit the source session’s current effort. Saving to TOML changes the default for future sessions; it is not required to retain the effort of the current session. Model selection still starts from the resolved CLI/TOML default on reload. Reasoning-effort records require a Kit version that understands transcript schema 6; older versions reject these records rather than silently discard the saved selection. Before changing models, Kit compares the latest provider-reported transcript occupancy with the target model's advertised context window. It adds a 20% tokenizer margin (`ceil(tokens × 1.20)`) and warns when that estimate is at least 80% of the target window. This is the latest request's occupancy, not accumulated session usage; cached input tokens are not counted twice. diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index 0583364..586f7da 100644 --- a/src/protocols/acp.rs +++ b/src/protocols/acp.rs @@ -104,6 +104,9 @@ impl<'a> AuthenticationRequiredData<'a> { } } +/// Terminal auth method metadata naming the provider the method signs in to. +pub(crate) const AUTH_PROVIDER_META: &str = "kit/provider"; + pub(crate) struct TerminalAuthMethodSpec { pub(crate) method_id: &'static str, pub(crate) name: &'static str, diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index d758ba6..68a3055 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -143,7 +143,11 @@ fn terminal_auth_methods( .args(vec![ "--terminal-auth-login".into(), method.method_id.into(), - ]), + ]) + .meta(Map::from_iter([( + super::AUTH_PROVIDER_META.into(), + Value::from(method.provider.as_str()), + )])), ) }) .collect() @@ -1592,7 +1596,7 @@ async fn session_actor(actor: SessionActor) structured_completion, mut skill_catalog, adapter, - catalog, + mut catalog, mut commands, mut mcp_events, } = actor; @@ -1660,6 +1664,15 @@ async fn session_actor(actor: SessionActor) { return Err(model_switch::error("model change cancelled")); } + // A terminal login since the session opened changes which + // providers resolve credentials; rebuild the catalog so the + // client's post-login refresh advertises their models. + if !catalog_matches_providers(&catalog, &adapter.catalog_providers()) { + let current = adapter + .selection() + .map_err(|error| model_switch::error(&error))?; + catalog = adapter.model_catalog(¤t).await; + } if request.config_id.to_string() == super::MODEL_CONFIG_ID { let target = request.value.as_id().ok_or_else(|| { model_switch::error("selection requires an id value") @@ -2366,6 +2379,16 @@ fn v2_config_options( ] } +fn catalog_matches_providers( + catalog: &[crate::provider::ModelGroup], + providers: &[crate::ProviderKind], +) -> bool { + catalog + .iter() + .map(|group| group.provider) + .eq(providers.iter().copied()) +} + fn set_v2_config( adapter: &SelectableAdapter, catalog: &[crate::provider::ModelGroup], @@ -3022,6 +3045,25 @@ pub(crate) fn component( )] mod tests { include!("v2/identity_tests.rs"); + #[test] + fn catalog_matches_providers_detects_logins_and_logouts() { + let group = |provider| crate::provider::ModelGroup { + provider, + models: Vec::new(), + context_windows: Default::default(), + }; + let catalog = [group(ProviderKind::OpenAiSubscription)]; + assert!(catalog_matches_providers( + &catalog, + &[ProviderKind::OpenAiSubscription] + )); + assert!(!catalog_matches_providers( + &catalog, + &[ProviderKind::OpenAiSubscription, ProviderKind::Anthropic] + )); + assert!(!catalog_matches_providers(&catalog, &[])); + } + #[test] fn compose_title_replay_preserves_identity_and_trims_intent() { let call = agentkit_core::ToolCallPart::new( @@ -6789,6 +6831,23 @@ mod tests { }); let method_ids = terminal_auth_method_ids(&methods); assert_eq!(method_ids, ["openai", "speakeasy", "anthropic"]); + let providers = methods + .iter() + .map(|method| match method { + wire::AuthMethod::Terminal(method) => { + method.meta.as_ref().unwrap()[crate::protocols::acp::AUTH_PROVIDER_META].clone() + } + _ => unreachable!("only terminal methods are advertised"), + }) + .collect::>(); + assert_eq!( + providers, + [ + json!("openai-subscription"), + json!("speakeasy"), + json!("anthropic") + ] + ); assert!( terminal_auth_method_specs() .iter() diff --git a/src/provider/adapter.rs b/src/provider/adapter.rs index d9af61b..a94b4fc 100644 --- a/src/provider/adapter.rs +++ b/src/provider/adapter.rs @@ -357,6 +357,15 @@ impl SelectableAdapter { .await } + /// Providers whose credentials resolve now, in catalog order. + pub fn catalog_providers(&self) -> Vec { + available_catalog_providers( + &self.credential_storage, + self.openrouter_api_key.as_ref(), + |name| std::env::var(name), + ) + } + pub fn select(&self, selection: ModelSelection) -> Result<(), String> { if !valid_model_id(&selection.model) { return Err("model name is outside canonical bounds".into()); diff --git a/src/tui/app.rs b/src/tui/app.rs index eb02ab1..5714458 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -242,6 +242,12 @@ pub struct ProviderRow<'a> { pub login: Option<&'a AuthMethodTerminal>, } +/// A sign-in started from the provider page, resumed on its models afterward. +struct ProviderLogin { + provider: String, + save_defaults: bool, +} + pub struct ModelDialog { pub query: String, pub selected: usize, @@ -864,6 +870,10 @@ pub struct App { pub provider: String, pub model: String, pub model_choices: Vec, + /// Providers with resolved credentials, from the catalog's model groups; + /// `None` until the agent advertises a catalog. + catalog_providers: Option>, + provider_login: Option, pub model_dialog: Option, pub(super) elicitations: VecDeque, pub model_switch: Option, @@ -1167,6 +1177,8 @@ impl App { provider, model, model_choices: Vec::new(), + catalog_providers: None, + provider_login: None, model_dialog: None, elicitations: VecDeque::new(), model_switch: None, @@ -3586,14 +3598,24 @@ impl App { self.model_choices = choices; } + pub fn set_catalog_providers(&mut self, providers: Option>) { + self.catalog_providers = providers; + } + pub fn set_effort(&mut self, current: String, choices: Vec) { self.reasoning_effort = current; self.effort_choices = choices; } - /// Distinct providers in catalog order. + /// Distinct signed-in providers in catalog order, including any whose + /// model discovery came back empty. fn model_providers(&self) -> Vec<&str> { - let mut providers = Vec::<&str>::new(); + let mut providers = self + .catalog_providers + .iter() + .flatten() + .map(String::as_str) + .collect::>(); for choice in &self.model_choices { if !providers.contains(&choice.provider.as_str()) { providers.push(&choice.provider); @@ -3608,23 +3630,33 @@ impl App { .filter(move |choice| choice.provider == provider) } - /// Advertised sign-in methods for providers that have no models yet. - fn login_providers(&self) -> Vec> { + /// Signed-in providers followed by advertised sign-in methods for the + /// rest. Sign-in rows need the agent's catalog to know who is signed in. + fn provider_rows(&self) -> (Vec>, Vec>) { let active = self.model_providers(); - self.auth_methods - .iter() - .filter_map(|method| { - let name = crate::protocols::acp::terminal_auth_method_specs() - .iter() - .find(|spec| spec.method_id == method.method_id.0.as_ref())? - .provider - .as_str(); - (!active.contains(&name)).then_some(ProviderRow { - name, - login: Some(method), + let signed_out = if self.catalog_providers.is_some() { + self.auth_methods + .iter() + .filter_map(|method| { + let name = method + .meta + .as_ref()? + .get(crate::protocols::acp::AUTH_PROVIDER_META)? + .as_str()?; + (!active.contains(&name)).then_some(ProviderRow { + name, + login: Some(method), + }) }) - }) - .collect() + .collect() + } else { + Vec::new() + }; + let active = active + .into_iter() + .map(|name| ProviderRow { name, login: None }) + .collect(); + (active, signed_out) } /// Providers matching the search on the provider page, with signed-out @@ -3632,21 +3664,31 @@ impl App { pub fn selected_model_providers(&self) -> Vec> { match &self.model_dialog { Some(dialog) if dialog.provider.is_none() => { - let active = self - .model_providers() - .into_iter() - .map(|name| ProviderRow { name, login: None }) - .collect(); + let (active, signed_out) = self.provider_rows(); let mut rows = rank_by_name(active, &dialog.query, |row| row.name); - rows.extend(rank_by_name(self.login_providers(), &dialog.query, |row| { - row.name - })); + rows.extend(rank_by_name(signed_out, &dialog.query, |row| row.name)); rows } _ => Vec::new(), } } + /// Reopens the model page of a provider signed in from the picker, once the + /// refreshed catalog lists it. + pub fn finish_provider_login(&mut self) { + let Some(login) = self.provider_login.take() else { + return; + }; + if self.working() || !self.model_providers().contains(&login.provider.as_str()) { + return; + } + self.open_model_dialog(); + self.open_model_provider(login.provider); + if let Some(dialog) = &mut self.model_dialog { + dialog.save_defaults = login.save_defaults; + } + } + /// Models of the chosen provider matching the search; empty on the provider page. pub fn selected_model_choices(&self) -> Vec<&ModelChoice> { let Some(dialog) = &self.model_dialog else { @@ -3771,20 +3813,27 @@ impl App { .as_ref() .is_some_and(|dialog| dialog.provider.is_none()) { - let row = self.selected_model_providers().get(selected).copied(); + let row = self + .selected_model_providers() + .get(selected) + .map(|row| (row.name.to_owned(), row.login.cloned())); match row { - Some(ProviderRow { - login: Some(method), - .. - }) => { - let method = method.clone(); - self.model_dialog = None; + Some((provider, Some(method))) => { + if self.model_switch.is_some() || self.working() { + self.toast("sign in after the current turn finishes"); + return Action::None; + } + let save_defaults = self + .model_dialog + .take() + .is_some_and(|dialog| dialog.save_defaults); + self.provider_login = Some(ProviderLogin { + provider, + save_defaults, + }); return Action::Login(method); } - Some(ProviderRow { name, login: None }) => { - let provider = name.to_owned(); - self.open_model_provider(provider); - } + Some((provider, None)) => self.open_model_provider(provider), None => {} } return Action::None; @@ -4745,7 +4794,8 @@ impl App { } }, Parsed::Model { query: None } => { - if self.model_choices.is_empty() && self.auth_methods.is_empty() { + let (active, signed_out) = self.provider_rows(); + if active.is_empty() && signed_out.is_empty() { self.toast("no models are available"); } else { self.open_model_dialog(); @@ -9104,15 +9154,30 @@ mod tests { .collect() } - #[test] - fn model_dialog_lists_signed_out_providers_after_active_ones_and_logs_in() { + fn sign_in(method_id: &str, provider: &str) -> AuthMethodTerminal { + AuthMethodTerminal::new(method_id, format!("Sign in to {provider}")).meta( + serde_json::Map::from_iter([( + crate::protocols::acp::AUTH_PROVIDER_META.into(), + serde_json::Value::from(provider), + )]), + ) + } + + fn signed_in_app(providers: &[&str]) -> App { let mut app = app(); app.set_model_choices(vec![model_choice("openai-subscription", "gpt-5.4")]); + app.set_catalog_providers(Some(providers.iter().map(|&name| name.into()).collect())); app.auth_methods = vec![ - AuthMethodTerminal::new("openai", "Sign in with ChatGPT"), - AuthMethodTerminal::new("openrouter", "Sign in with OpenRouter"), - AuthMethodTerminal::new("anthropic", "Sign in with Anthropic"), + sign_in("openai", "openai-subscription"), + sign_in("openrouter", "openrouter"), + sign_in("anthropic", "anthropic"), ]; + app + } + + #[test] + fn model_dialog_lists_signed_out_providers_after_active_ones_and_logs_in() { + let mut app = signed_in_app(&["openai-subscription"]); app.open_model_dialog(); let rows = app.selected_model_providers(); @@ -9127,6 +9192,7 @@ mod tests { [false, true, true] ); + app.handle_key(press(KeyCode::Tab)); for character in "anthr".chars() { app.handle_key(press(KeyCode::Char(character))); } @@ -9136,15 +9202,88 @@ mod tests { }; assert_eq!(method.method_id.0.as_ref(), "anthropic"); assert!(app.model_dialog.is_none()); + + // The refreshed catalog lists the provider, so its models reopen with + // the defaults choice the user made before signing in. + app.set_model_choices(vec![ + model_choice("openai-subscription", "gpt-5.4"), + model_choice("anthropic", "claude-sonnet-4-5"), + ]); + app.set_catalog_providers(Some(vec!["openai-subscription".into(), "anthropic".into()])); + app.finish_provider_login(); + let dialog = app.model_dialog.as_ref().expect("model dialog"); + assert_eq!(dialog.provider.as_deref(), Some("anthropic")); + assert!(dialog.save_defaults); + assert_eq!( + app.selected_model_choices() + .iter() + .map(|choice| choice.model.as_str()) + .collect::>(), + ["claude-sonnet-4-5"] + ); + } + + #[test] + fn failed_provider_sign_in_does_not_reopen_the_dialog() { + let mut app = signed_in_app(&["openai-subscription"]); + app.open_model_dialog(); + for character in "anthr".chars() { + app.handle_key(press(KeyCode::Char(character))); + } + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::Login(_) + )); + app.finish_provider_login(); + assert!(app.model_dialog.is_none()); + } + + #[test] + fn signed_in_providers_without_models_are_not_offered_sign_in() { + // Anthropic has credentials but its model discovery came back empty. + let mut app = signed_in_app(&["openai-subscription", "anthropic"]); + app.open_model_dialog(); + let rows = app.selected_model_providers(); + assert_eq!( + rows.iter() + .map(|row| (row.name, row.login.is_some())) + .collect::>(), + [ + ("openai-subscription", false), + ("anthropic", false), + ("openrouter", true) + ] + ); + } + + #[test] + fn provider_sign_in_waits_for_the_current_turn() { + let mut app = signed_in_app(&["openai-subscription"]); + app.open_model_dialog(); + app.phase = Phase::Working; + app.model_dialog.as_mut().unwrap().selected = 2; + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert!(app.model_dialog.is_some()); + assert!(app.provider_login.is_none()); } #[test] - fn model_command_opens_the_provider_page_with_only_sign_in_methods() { + fn model_command_offers_sign_in_only_once_the_catalog_is_known() { let mut app = app(); - app.auth_methods = vec![AuthMethodTerminal::new( - "anthropic", - "Sign in with Anthropic", - )]; + app.auth_methods = vec![ + sign_in("anthropic", "anthropic"), + AuthMethodTerminal::new("unknown", "Sign in elsewhere"), + ]; + app.editor.insert_str("/model"); + app.handle_key(press(KeyCode::Enter)); + assert!(app.model_dialog.is_none()); + + app.set_catalog_providers(Some(Vec::new())); + app.editor.clear(); + app.last_key = None; app.editor.insert_str("/model"); app.handle_key(press(KeyCode::Enter)); assert_eq!(provider_names(&app), ["anthropic"]); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 6a13934..f7d3c57 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -71,8 +71,8 @@ use tokio::{ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use wire::{ CancelSessionNotification, CloseSessionRequest, ContentBlock, SessionConfigKind, - SessionConfigOption, SessionConfigSelectOptions, SessionUpdate, SetSessionConfigOptionRequest, - ToolCallContent, UpdateSessionNotification, + SessionConfigOption, SessionConfigSelectGroup, SessionConfigSelectOptions, SessionUpdate, + SetSessionConfigOptionRequest, ToolCallContent, UpdateSessionNotification, }; use crate::{ @@ -1270,6 +1270,12 @@ fn refresh_config_state(app: &mut App, options: Option<&[SessionConfigOption]>) app.model = choice.model; } app.set_model_choices(model_choices(options)); + app.set_catalog_providers(model_groups(options).map(|groups| { + groups + .iter() + .map(|group| group.group_id.to_string()) + .collect() + })); if let Some((current, choices)) = effort_state(options) { app.set_effort(current, choices); } else { @@ -1277,21 +1283,25 @@ fn refresh_config_state(app: &mut App, options: Option<&[SessionConfigOption]>) } } -fn model_choices(options: Option<&[SessionConfigOption]>) -> Vec { - let Some(SessionConfigOption { - kind: SessionConfigKind::Select(select), - .. - }) = options +/// The model option's provider groups, one per provider with credentials. +fn model_groups(options: Option<&[SessionConfigOption]>) -> Option<&[SessionConfigSelectGroup]> { + let SessionConfigKind::Select(select) = &options .unwrap_or_default() .iter() - .find(|option| option.config_id.to_string() == MODEL_CONFIG_ID) + .find(|option| option.config_id.to_string() == MODEL_CONFIG_ID)? + .kind else { - return Vec::new(); - }; - let SessionConfigSelectOptions::Grouped(groups) = &select.options else { - return Vec::new(); + return None; }; - groups + match &select.options { + SessionConfigSelectOptions::Grouped(groups) => Some(groups), + _ => None, + } +} + +fn model_choices(options: Option<&[SessionConfigOption]>) -> Vec { + model_groups(options) + .unwrap_or_default() .iter() .flat_map(|group| { let provider = group.group_id.to_string(); @@ -2781,6 +2791,7 @@ async fn run_client( method.name )), } + app.finish_provider_login(); images = resume_terminal(&mut terminal).map_err( agent_client_protocol::Error::into_internal_error, )?; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index bd7e793..0f5befb 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -814,6 +814,12 @@ fn draw_effort_dialog(frame: &mut Frame<'_>, app: &App, dialog: &EffortDialog) { ); } +/// A model dialog row; signed-out providers start a sign-in when entered. +struct DialogRow<'a> { + label: &'a str, + signed_out: bool, +} + fn draw_model_dialog(frame: &mut Frame<'_>, app: &App, dialog: &ModelDialog) { let outer = frame.area(); let width = if outer.width > 20 { @@ -832,12 +838,14 @@ fn draw_model_dialog(frame: &mut Frame<'_>, app: &App, dialog: &ModelDialog) { width, height, ); - // Rows flagged `true` are signed-out providers; entering one starts login. - let (labels, title, esc) = match &dialog.provider { + let (rows, title, esc) = match &dialog.provider { None => ( app.selected_model_providers() .into_iter() - .map(|row| (row.name.to_owned(), row.login.is_some())) + .map(|row| DialogRow { + label: row.name, + signed_out: row.login.is_some(), + }) .collect::>(), " provider ".to_owned(), "close", @@ -845,15 +853,18 @@ fn draw_model_dialog(frame: &mut Frame<'_>, app: &App, dialog: &ModelDialog) { Some(provider) => ( app.selected_model_choices() .into_iter() - .map(|choice| (choice.model.clone(), false)) + .map(|choice| DialogRow { + label: &choice.model, + signed_out: false, + }) .collect(), format!(" {provider} · model "), "back", ), }; - let enter = match (&dialog.provider, labels.get(dialog.selected)) { + let enter = match (&dialog.provider, rows.get(dialog.selected)) { (Some(_), _) => "select", - (None, Some((_, true))) => "sign in", + (None, Some(row)) if row.signed_out => "sign in", (None, _) => "open", }; let footer = format!( @@ -871,20 +882,21 @@ fn draw_model_dialog(frame: &mut Frame<'_>, app: &App, dialog: &ModelDialog) { ]) .areas(inner); let visible = list.height as usize; - let viewport = model_dialog_viewport(labels.len(), dialog.selected, visible); - let lines = labels[viewport.clone()] + let viewport = model_dialog_viewport(rows.len(), dialog.selected, visible); + let lines = rows[viewport.clone()] .iter() .zip(viewport) - .map(|((label, signed_out), index)| { + .map(|(row, index)| { let selected = index == dialog.selected; let marker = if selected { "› " } else { " " }; - let style = match (selected, *signed_out) { + // Signed-out providers stay greyed out, keeping the accent when selected. + let style = match (selected, row.signed_out) { (true, false) => theme::accent(), - (true, true) => theme::dim(), + (true, true) => theme::accent().add_modifier(Modifier::DIM), (false, false) => theme::text(), (false, true) => theme::faint(), }; - Line::from(Span::styled(format!("{marker}{label}"), style)) + Line::from(Span::styled(format!("{marker}{}", row.label), style)) }) .collect::>(); frame.render_widget(Clear, area); @@ -4837,6 +4849,21 @@ mod tests { assert!(frame.contains("› beta"), "{frame}"); assert!(!frame.contains("three"), "{frame}"); assert!(frame.contains("esc close"), "{frame}"); + assert!(frame.contains("enter open"), "{frame}"); + + app.set_catalog_providers(Some(vec!["alpha".into(), "beta".into()])); + app.auth_methods = vec![ + agent_client_protocol::schema::v2::AuthMethodTerminal::new("gamma", "Gamma").meta( + serde_json::Map::from_iter([( + crate::protocols::acp::AUTH_PROVIDER_META.into(), + serde_json::Value::from("gamma"), + )]), + ), + ]; + app.model_dialog.as_mut().unwrap().selected = 2; + let signed_out = render(&mut app, 60, 12); + assert!(signed_out.contains("› gamma"), "{signed_out}"); + assert!(signed_out.contains("enter sign in"), "{signed_out}"); app.model_dialog.as_mut().unwrap().provider = Some("alpha".into()); app.model_dialog.as_mut().unwrap().selected = 0; From a18ac08ca3b7a92e3bfedba349e00409437e7a34 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Thu, 22 Oct 2026 13:25:04 -0700 Subject: [PATCH 3/3] fix(tui): explain empty model picker pages A signed-in provider whose model discovery failed now shows why its model page is empty, a search with no results says so, and the footer drops the enter hint when there is nothing to select. Co-Authored-By: Claude Opus 5.5 --- src/tui/ui.rs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 0f5befb..c81650e 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -863,12 +863,13 @@ fn draw_model_dialog(frame: &mut Frame<'_>, app: &App, dialog: &ModelDialog) { ), }; let enter = match (&dialog.provider, rows.get(dialog.selected)) { - (Some(_), _) => "select", - (None, Some(row)) if row.signed_out => "sign in", - (None, _) => "open", + (_, None) => "", + (Some(_), _) => " · enter select", + (None, Some(row)) if row.signed_out => " · enter sign in", + (None, _) => " · enter open", }; let footer = format!( - "tab defaults [{}] · enter {enter} · esc {esc}", + "tab defaults [{}]{enter} · esc {esc}", if dialog.save_defaults { "x" } else { " " } ); let panel = Panel::bordered().title(title); @@ -899,6 +900,15 @@ fn draw_model_dialog(frame: &mut Frame<'_>, app: &App, dialog: &ModelDialog) { Line::from(Span::styled(format!("{marker}{}", row.label), style)) }) .collect::>(); + // A signed-in provider can have no models when its discovery failed. + let lines = match (lines.is_empty(), dialog.query.is_empty(), &dialog.provider) { + (true, true, Some(_)) => vec![Line::from(Span::styled( + " no models available; model discovery may have failed", + theme::dim(), + ))], + (true, false, _) => vec![Line::from(Span::styled(" no matches", theme::dim()))], + _ => lines, + }; frame.render_widget(Clear, area); frame.render_widget(panel, area); let search_prefix = if search.width >= 8 { @@ -4865,6 +4875,19 @@ mod tests { assert!(signed_out.contains("› gamma"), "{signed_out}"); assert!(signed_out.contains("enter sign in"), "{signed_out}"); + // A signed-in provider whose discovery returned nothing explains the + // empty page instead of offering a selection. + app.set_catalog_providers(Some(vec!["alpha".into(), "beta".into(), "delta".into()])); + app.model_dialog.as_mut().unwrap().provider = Some("delta".into()); + app.model_dialog.as_mut().unwrap().selected = 0; + let empty = render(&mut app, 70, 12); + assert!(empty.contains("no models available"), "{empty}"); + assert!(!empty.contains("enter select"), "{empty}"); + app.model_dialog.as_mut().unwrap().query = "zzz".into(); + let unmatched = render(&mut app, 70, 12); + assert!(unmatched.contains("no matches"), "{unmatched}"); + app.model_dialog.as_mut().unwrap().query.clear(); + app.model_dialog.as_mut().unwrap().provider = Some("alpha".into()); app.model_dialog.as_mut().unwrap().selected = 0; let models = render(&mut app, 60, 12);