diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 45d597dd..45cada73 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 0583364f..586f7daf 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 d758ba69..68a30555 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 d9af61ba..a94b4fcc 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 26402f52..5714458a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -234,6 +234,20 @@ 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>, +} + +/// 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, @@ -856,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, @@ -1159,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, @@ -3578,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); @@ -3600,16 +3630,65 @@ 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> { + /// 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(); + 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() + } 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 + /// 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, signed_out) = self.provider_rows(); + let mut rows = rank_by_name(active, &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 { @@ -3734,12 +3813,28 @@ impl App { .as_ref() .is_some_and(|dialog| dialog.provider.is_none()) { - let provider = self + let row = self .selected_model_providers() .get(selected) - .map(|provider| (*provider).to_owned()); - if let Some(provider) = provider { - self.open_model_provider(provider); + .map(|row| (row.name.to_owned(), row.login.cloned())); + match row { + 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((provider, None)) => self.open_model_provider(provider), + None => {} } return Action::None; } @@ -4699,7 +4794,8 @@ impl App { } }, Parsed::Model { query: None } => { - if self.model_choices.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(); @@ -9051,6 +9147,148 @@ mod tests { ] } + fn provider_names(app: &App) -> Vec<&str> { + app.selected_model_providers() + .into_iter() + .map(|row| row.name) + .collect() + } + + 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![ + 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(); + 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] + ); + + app.handle_key(press(KeyCode::Tab)); + 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()); + + // 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_offers_sign_in_only_once_the_catalog_is_known() { + let mut app = app(); + 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"]); + } + #[test] fn sessions_command_defers_catalog_work_to_the_event_loop() { let mut app = app(); @@ -9361,7 +9599,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 +9608,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/mod.rs b/src/tui/mod.rs index 6a139342..f7d3c573 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 df250f00..c81650ed 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,28 +838,38 @@ fn draw_model_dialog(frame: &mut Frame<'_>, app: &App, dialog: &ModelDialog) { width, height, ); - let (labels, title, enter, esc) = match &dialog.provider { + let (rows, title, esc) = match &dialog.provider { None => ( app.selected_model_providers() .into_iter() - .map(str::to_owned) + .map(|row| DialogRow { + label: row.name, + signed_out: 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| DialogRow { + label: &choice.model, + signed_out: false, + }) .collect(), format!(" {provider} · model "), - "select", "back", ), }; + let enter = match (&dialog.provider, rows.get(dialog.selected)) { + (_, 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); @@ -867,21 +883,32 @@ 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, index)| { + .map(|(row, index)| { let selected = index == dialog.selected; let marker = if selected { "› " } else { " " }; - let style = if selected { - theme::accent() - } else { - theme::text() + // 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::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::>(); + // 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 { @@ -4832,6 +4859,34 @@ 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}"); + + // 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;