feat(login): custom provider logins fetch and select models

After the base URL, name, and key steps, the login now opens a model
selection screen instead of blindly saving. The listing fetch runs
concurrently with a manual-id input — a WAF challenge page or a 404 no
longer passes validation silently: a base without a /vN tail is probed
once as {base}/v1 and the working base is adopted, an anthropic-wire
listing that 401s on x-api-key retries once with Bearer (relays gate
GET /models on Bearer while /messages takes the key header), and a
failed fetch shows its reason while manual add stays usable. The chosen
ids persist as [platforms.<name>] models = [...]; catalog assembly
synthesizes selected ids the listing never served (before [model.*]
overrides so they compose) and filters unselected provider entries
(after overrides, authoritative in the provider namespace). Provider-
owned entries keep their declared wire against donor backend
inheritance, and listing errors quote line/column only — a reflected
body could carry the key.
This commit is contained in:
雷电芽衣 committed 2026-09-29 22:00:20 -04:00
1 parent 1d631a07fa
commit 9a2a9eac99
20 files changed
+1655 -68

No files matched your search

@@ -574,6 +574,8 @@ pub enum Action {
BeginCustomProviderEntry(kigi_shell::models::custom::CustomApi),
/// Enter in a custom provider step.
SubmitCustomProviderInput(String),
/// Enter on an empty manual-model box: finish the model selection.
SubmitCustomProviderSelection,
/// Copy the auth URL to the clipboard during authentication.
CopyAuthUrl,
/// Show the raw auth URL with mouse capture disabled for manual copy.
@@ -1623,6 +1625,13 @@ pub enum Effect {
request_seq: u64,
provider: kigi_shell::models::custom::CustomProvider,
key: String,
models: Vec<String>,
},
/// List the provider's models for the login selection screen.
FetchCustomProviderModels {
request_seq: u64,
provider: kigi_shell::models::custom::CustomProvider,
key: String,
},
/// Fetch MCP server list from the shell (kigi/mcp/list).
FetchMcpsList {
@@ -2166,6 +2175,11 @@ pub enum TaskResult {
AuthCodeSubmitted {
request_seq: u64,
},
/// The login-step model listing came back (or failed).
CustomProviderModelsListed {
request_seq: u64,
result: Result<kigi_shell::agent::custom_providers::LoginFetch, String>,
},
/// MCP server list fetched from shell.
McpsListLoaded {
agent_id: AgentId,
+96 -7
View File
@@ -264,6 +264,8 @@ pub enum AuthMode {
ApiKeyEntry(PlatformLogin),
/// Custom provider login, one field per step.
CustomProviderEntry(crate::app::custom_entry::CustomEntryStep),
/// Custom provider login, the model-selection screen.
CustomProviderSelect,
}
/// API-key platform login target, selected from the welcome picker. Wraps a
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
@@ -924,6 +926,8 @@ pub struct AppView {
pub auth_code_input: String,
/// Values collected by the custom provider login steps.
pub custom_entry: crate::app::custom_entry::CustomEntryDraft,
/// Model-selection state of a custom provider login; present in that mode.
pub custom_select: Option<crate::app::custom_entry::CustomSelectState>,
/// Monotonically increasing sequence number for auth requests.
pub next_auth_request_seq: u64,
/// Every session/chat/worktree/prompt action deferred behind startup gates.
@@ -1128,6 +1132,7 @@ impl AppView {
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
custom_entry: Default::default(),
custom_select: None,
next_auth_request_seq: 1,
deferred_startup: Default::default(),
auth_use_oauth: false,
@@ -1663,6 +1668,7 @@ impl AppView {
mid_session_login: self.auth_return_view.is_some(),
auth_methods: &self.auth_methods,
auth_code_input: &mut self.auth_code_input,
custom_select: &mut self.custom_select,
prompt: &mut self.welcome_prompt,
prompt_focused: &mut self.welcome_prompt_focused,
new_worktree_dialog: &mut self.new_worktree_dialog,
@@ -2188,6 +2194,7 @@ struct WelcomeInputCtx<'a> {
/// the `AuthState::Pending` welcome menu.
auth_methods: &'a [acp::AuthMethod],
auth_code_input: &'a mut String,
custom_select: &'a mut Option<crate::app::custom_entry::CustomSelectState>,
prompt: &'a mut PromptWidget,
prompt_focused: &'a mut bool,
new_worktree_dialog: &'a mut Option<NewWorktreeDialogState>,
@@ -2712,6 +2719,70 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
return InputOutcome::Changed;
}
}
AuthState::Authenticating {
mode: AuthMode::CustomProviderSelect,
..
} => {
// Esc cancels back to the picker, like the entry steps.
if key!(Esc).matches(key) {
return InputOutcome::Action(Action::CancelPlatformKeyEntry);
}
if key!('q', CONTROL).matches(key) || key!('c', CONTROL).matches(key) {
if ctx.mid_session_login {
return InputOutcome::Action(Action::CancelLogin);
}
return InputOutcome::Action(Action::QuitConfirmed);
}
let Some(select) = ctx.custom_select.as_mut() else {
return InputOutcome::Unchanged;
};
if key!(Up).matches(key) {
select.move_highlight(-1);
return InputOutcome::Changed;
}
if key!(Down).matches(key) {
select.move_highlight(1);
return InputOutcome::Changed;
}
// Model ids hold no spaces, so the spacebar toggles rows.
if key!(Char(' ')).matches(key) {
select.toggle_highlighted();
return InputOutcome::Changed;
}
if crate::input::key::is_paste_key(key)
|| crate::input::key::is_inline_paste_key(key)
{
if let Some(text) = crate::clipboard::system_clipboard_get() {
let cleaned: String =
text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
ctx.auth_code_input.push_str(&cleaned);
return InputOutcome::Changed;
}
crate::clipboard::log_paste_key_empty_host_clipboard("auth input");
return InputOutcome::Unchanged;
}
if key!(Enter).matches(key) {
let trimmed = ctx.auth_code_input.trim().to_string();
if trimmed.is_empty() {
return InputOutcome::Action(Action::SubmitCustomProviderSelection);
}
select.add_manual(&trimmed);
if select.error.is_none() {
ctx.auth_code_input.clear();
}
return InputOutcome::Changed;
}
if key!(Backspace).matches(key) {
ctx.auth_code_input.pop();
return InputOutcome::Changed;
}
if let crossterm::event::KeyCode::Char(c) = key.code
&& crate::input::key::is_text_input_key(key)
{
ctx.auth_code_input.push(c);
return InputOutcome::Changed;
}
}
AuthState::Authenticating {
mode: AuthMode::Loopback,
..
@@ -2775,7 +2846,10 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
}
AuthState::Authenticating {
mode:
AuthMode::Loopback | AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
AuthMode::Loopback
| AuthMode::ApiKeyEntry(_)
| AuthMode::CustomProviderEntry(_)
| AuthMode::CustomProviderSelect,
..
} => {
let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
@@ -3276,6 +3350,7 @@ impl AppView {
login_label: self.login_label.as_deref(),
auth_code_input: &self.auth_code_input,
custom_entry: &self.custom_entry,
custom_select: self.custom_select.as_ref(),
clipboard_copied: self.auth_clipboard_copied,
show_raw_url: self.auth_show_raw_url,
tip,
@@ -4412,6 +4487,7 @@ pub(crate) mod tests {
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
custom_entry: Default::default(),
custom_select: None,
next_auth_request_seq: 1,
deferred_startup: Default::default(),
auth_use_oauth: false,
@@ -7632,12 +7708,24 @@ pub(crate) mod tests {
/// Ctrl+V / Super+V paste the host clipboard into every auth input.
#[test]
fn auth_inputs_paste_key_chords_insert_clipboard_text() {
let select_state = || {
crate::app::custom_entry::CustomSelectState::new(
kigi_shell::models::custom::CustomProvider::new(
"gw",
kigi_shell::models::custom::CustomApi::OpenAi,
"https://h.example/v1",
)
.unwrap(),
"sk".to_owned(),
)
};
let modes = [
AuthMode::Loopback,
AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
AuthMode::CustomProviderEntry(crate::app::custom_entry::CustomEntryStep::first(
kigi_shell::models::custom::CustomApi::OpenAi,
)),
AuthMode::CustomProviderSelect,
];
for mode in modes {
for mods in [KeyModifiers::CONTROL, KeyModifiers::SUPER] {
@@ -7648,12 +7736,13 @@ pub(crate) mod tests {
auth_url: None,
mode,
};
crate::clipboard::set_clipboard_probe_hook(
crate::clipboard::ClipboardProbeHook {
text: Some("sk-pasted\nkey".to_owned()),
..Default::default()
},
);
if matches!(mode, AuthMode::CustomProviderSelect) {
app.custom_select = Some(select_state());
}
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
text: Some("sk-pasted\nkey".to_owned()),
..Default::default()
});
let outcome = app.handle_input(&key_event(KeyCode::Char('v'), mods));
crate::clipboard::clear_clipboard_probe_hook();
assert!(
+201 -1
View File
@@ -1,7 +1,7 @@
//! Custom provider login steps: base URL, name, key.
use kigi_shell::models::custom::{
CustomApi, CustomProvider, default_name, normalize_base_url, validate_name,
CustomApi, CustomProvider, default_name, normalize_base_url, validate_model_id, validate_name,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -141,6 +141,141 @@ impl CustomEntryDraft {
}
}
/// Fetch status of the model-selection screen.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectFetch {
InFlight,
Listed,
Failed(String),
}
/// One selectable model row; `manual` marks user-typed ids.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectRow {
pub id: String,
pub selected: bool,
pub manual: bool,
}
/// Model selection after the key step: fetched rows plus manual adds.
#[derive(Clone)]
pub struct CustomSelectState {
/// The fetch may adopt a `/v1` base URL.
pub provider: CustomProvider,
key: String,
pub rows: Vec<SelectRow>,
pub highlight: usize,
pub fetch: SelectFetch,
pub error: Option<String>,
/// Enter hit while the listing is in flight; it completes on arrival.
pub finish_requested: bool,
}
impl CustomSelectState {
pub fn new(provider: CustomProvider, key: String) -> Self {
Self {
provider,
key,
rows: Vec::new(),
highlight: 0,
fetch: SelectFetch::InFlight,
error: None,
finish_requested: false,
}
}
pub fn take_key(self) -> String {
self.key
}
pub fn apply_fetch(&mut self, fetch: kigi_shell::agent::custom_providers::LoginFetch) {
self.provider = fetch.provider;
for id in fetch.model_ids {
if self.rows.iter().any(|r| r.id == id) {
continue;
}
self.rows.push(SelectRow {
id,
selected: false,
manual: false,
});
}
self.fetch = SelectFetch::Listed;
}
pub fn fail_fetch(&mut self, reason: String) {
self.fetch = SelectFetch::Failed(reason);
}
pub fn move_highlight(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
let max = self.rows.len() - 1;
self.highlight = self.highlight.saturating_add_signed(delta).min(max);
}
pub fn toggle_highlighted(&mut self) {
if let Some(row) = self.rows.get_mut(self.highlight) {
row.selected = !row.selected;
}
}
/// Adds a typed id selected; an id already listed just gets selected.
pub fn add_manual(&mut self, raw: &str) {
let id = raw.trim();
if let Err(e) = validate_model_id(id) {
self.error = Some(e.to_string());
return;
}
if let Some(row) = self.rows.iter_mut().find(|r| r.id == id) {
row.selected = true;
} else {
self.rows.push(SelectRow {
id: id.to_owned(),
selected: true,
manual: true,
});
}
self.error = None;
}
/// The chosen ids, or a refusal when nothing is chosen.
pub fn try_finish(&mut self) -> Option<Vec<String>> {
// Wait for the listing: it may adopt a probed `/v1` base URL.
if self.fetch == SelectFetch::InFlight {
self.finish_requested = true;
return None;
}
let ids: Vec<String> = self
.rows
.iter()
.filter(|r| r.selected)
.map(|r| r.id.clone())
.collect();
if ids.is_empty() {
self.finish_requested = false;
self.error = Some("Select at least one model, or type one and press enter".to_owned());
return None;
}
Some(ids)
}
}
impl std::fmt::Debug for CustomSelectState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CustomSelectState")
.field("provider", &self.provider)
.field("key", &"<set>")
.field("rows", &self.rows)
.field("highlight", &self.highlight)
.field("fetch", &self.fetch)
.field("error", &self.error)
.field("finish_requested", &self.finish_requested)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -236,4 +371,69 @@ mod tests {
assert!(!step(CustomEntryField::BaseUrl).masks_input());
assert!(!step(CustomEntryField::Name).masks_input());
}
fn select_state() -> CustomSelectState {
let provider =
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
CustomSelectState::new(provider, "sk-secret".to_owned())
}
#[test]
fn select_state_toggles_adds_and_finishes() {
let mut state = select_state();
assert!(state.try_finish().is_none(), "in flight: finish waits");
assert!(state.finish_requested && state.error.is_none());
state.finish_requested = false;
state.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
provider: state.provider.clone(),
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
});
assert!(state.try_finish().is_none());
assert!(state.error.is_some(), "listed: an empty selection refuses");
assert_eq!(state.rows.len(), 2);
state.toggle_highlighted();
state.move_highlight(1);
state.toggle_highlighted();
state.move_highlight(9);
assert_eq!(state.highlight, 1, "highlight clamps at the last row");
state.add_manual(" claude-opus-5-5[1m] ");
assert_eq!(state.rows.len(), 3);
state.add_manual("m1");
assert_eq!(
state.rows.len(),
3,
"a listed id is selected, not duplicated"
);
state.add_manual("has space");
assert!(state.error.is_some());
assert_eq!(state.rows.len(), 3);
let ids = state.try_finish().expect("three selected");
assert_eq!(ids, ["m1", "m2", "claude-opus-5-5[1m]"]);
}
#[test]
fn select_state_apply_fetch_adopts_the_probed_base_and_dedups() {
let mut state = select_state();
state.add_manual("m1");
let adopted =
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/coding/v1").unwrap();
state.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
provider: adopted,
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
});
assert_eq!(state.provider.base_url, "https://h.example/coding/v1");
assert_eq!(state.rows.len(), 2, "the manual row covers the fetched id");
assert!(state.rows[0].manual && state.rows[0].selected);
assert!(!state.rows[1].selected);
}
#[test]
fn select_state_debug_hides_the_key() {
let state = select_state();
assert!(!format!("{state:?}").contains("sk-secret"));
}
}
@@ -282,6 +282,7 @@ pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec<Effect> {
app.auth_state = AuthState::Done;
app.auth_show_raw_url = false;
app.auth_code_input.clear();
app.custom_select = None;
app.auth_in_flight_method = None;
restore_auth_return_view(app, return_view);
// The user bailed out of re-auth — drop stashed prompts and strip the
@@ -335,6 +336,7 @@ pub(super) fn dispatch_begin_custom_provider_entry(
app.next_auth_request_seq += 1;
app.auth_code_input.clear();
app.custom_entry.clear();
app.custom_select = None;
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
@@ -373,18 +375,20 @@ pub(super) fn dispatch_submit_custom_provider_input(
vec![]
}
CustomEntryOutcome::Done { provider, key } => {
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
&provider.name,
));
// Key accepted: open the model selection and fetch concurrently.
app.custom_entry.clear();
app.auth_code_input.clear();
app.custom_select = Some(crate::app::custom_entry::CustomSelectState::new(
provider.clone(),
key.clone(),
));
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
mode: AuthMode::CustomProviderSelect,
};
vec![Effect::PersistCustomProviderAndAuthenticate {
vec![Effect::FetchCustomProviderModels {
request_seq,
provider,
key,
@@ -393,6 +397,81 @@ pub(super) fn dispatch_submit_custom_provider_input(
}
}
/// Enter on an empty manual-model box: persist the chosen models, then auth.
pub(super) fn dispatch_submit_custom_provider_selection(app: &mut AppView) -> Vec<Effect> {
let request_seq = match &app.auth_state {
AuthState::Authenticating {
request_seq,
mode: AuthMode::CustomProviderSelect,
..
} => *request_seq,
_ => return vec![],
};
complete_custom_provider_selection(app, request_seq)
}
/// Persists the selection once the listing no longer gates the base URL.
fn complete_custom_provider_selection(app: &mut AppView, request_seq: u64) -> Vec<Effect> {
let Some(state) = app.custom_select.as_mut() else {
return vec![];
};
let Some(models) = state.try_finish() else {
return vec![];
};
let state = app.custom_select.take().expect("checked above");
let provider = state.provider.clone();
let key = state.take_key();
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
&provider.name,
));
app.auth_code_input.clear();
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
vec![Effect::PersistCustomProviderAndAuthenticate {
request_seq,
provider,
key,
models,
}]
}
/// The login-step model listing came back; fill or flag the selection.
pub(super) fn handle_custom_provider_models_listed(
app: &mut AppView,
request_seq: u64,
result: Result<kigi_shell::agent::custom_providers::LoginFetch, String>,
) -> Vec<Effect> {
let in_flight = matches!(
&app.auth_state,
AuthState::Authenticating {
request_seq: current_seq,
mode: AuthMode::CustomProviderSelect,
..
} if *current_seq == request_seq
);
if !in_flight {
return vec![];
}
match (app.custom_select.as_mut(), result) {
(Some(state), Ok(fetch)) => state.apply_fetch(fetch),
(Some(state), Err(reason)) => state.fail_fetch(reason),
(None, _) => {}
}
// A finish pressed during the fetch completes now, with the adopted base.
if app
.custom_select
.as_ref()
.is_some_and(|s| s.finish_requested)
{
return complete_custom_provider_selection(app, request_seq);
}
vec![]
}
/// Esc in the API-key paste box: back to the login picker (no error line).
/// Bumps the request seq so any stale in-flight auth result is dropped by
/// the `AuthComplete`/`AuthFailed` guards.
@@ -400,7 +479,9 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
if !matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
mode: AuthMode::ApiKeyEntry(_)
| AuthMode::CustomProviderEntry(_)
| AuthMode::CustomProviderSelect,
..
}
) {
@@ -409,6 +490,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
app.next_auth_request_seq += 1;
app.auth_code_input.clear();
app.custom_entry.clear();
app.custom_select = None;
app.auth_state = AuthState::Pending { error: None };
vec![]
}
@@ -3,7 +3,8 @@ use super::auth::{
dispatch_begin_custom_provider_entry, dispatch_begin_platform_key_entry, dispatch_cancel_login,
dispatch_cancel_platform_key_entry, dispatch_login, dispatch_login_with, dispatch_logout,
dispatch_open_login_picker, dispatch_submit_auth_code, dispatch_submit_custom_provider_input,
dispatch_submit_platform_api_key, dispatch_switch_account,
dispatch_submit_custom_provider_selection, dispatch_submit_platform_api_key,
dispatch_switch_account,
};
use super::ctx::{
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
@@ -894,6 +895,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::SubmitCustomProviderInput(input) => {
dispatch_submit_custom_provider_input(app, input)
}
Action::SubmitCustomProviderSelection => dispatch_submit_custom_provider_selection(app),
Action::CopyAuthUrl => {
if let AuthState::Authenticating {
auth_url: Some(url),
@@ -509,9 +509,14 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
{
app.auth_state = AuthState::Pending { error: Some(error) };
app.auth_code_input.clear();
app.custom_select = None;
}
vec![]
}
TaskResult::CustomProviderModelsListed {
request_seq,
result,
} => super::auth::handle_custom_provider_models_listed(app, request_seq, result),
TaskResult::AuthUrlReady {
request_seq,
auth_url,
@@ -798,6 +803,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
app.login_method_id = None;
ensure_login_method(app);
app.auth_clipboard_copied = false;
app.custom_select = None;
let effects = dispatch_exit_session(app);
app.welcome_prompt_focused = false;
effects
@@ -582,9 +582,9 @@ fn custom_field(app: &AppView) -> crate::app::custom_entry::CustomEntryField {
}
}
/// URL, name, key; the last Enter emits one persist effect.
/// URL, name, key; the last Enter opens the selection screen and fetches.
#[test]
fn custom_provider_steps_end_in_one_persist_effect() {
fn custom_provider_steps_end_in_the_model_selection() {
use crate::app::custom_entry::CustomEntryField;
use kigi_shell::models::custom::CustomApi;
@@ -619,7 +619,7 @@ fn custom_provider_steps_end_in_one_persist_effect() {
let effects = dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
match effects.as_slice() {
[
Effect::PersistCustomProviderAndAuthenticate {
Effect::FetchCustomProviderModels {
request_seq,
provider,
key,
@@ -631,21 +631,236 @@ fn custom_provider_steps_end_in_one_persist_effect() {
assert_eq!(provider.base_url, "https://api.gw.example/v1");
assert_eq!(key, "sk-gw");
}
other => panic!("expected exactly the persist effect, got {other:?}"),
other => panic!("expected exactly the fetch effect, got {other:?}"),
}
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
request_seq,
mode: AuthMode::Pending,
mode: AuthMode::CustomProviderSelect,
..
} if request_seq == seq
));
assert!(app.custom_select.is_some());
assert!(app.auth_code_input.is_empty());
}
/// The listing fills rows; the selection persists only the chosen models.
#[test]
fn custom_provider_selection_persists_the_chosen_models() {
use kigi_shell::models::custom::{CustomApi, CustomProvider};
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
&mut app,
);
dispatch(
Action::SubmitCustomProviderInput("https://h.example".into()),
&mut app,
);
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
let seq = match &app.auth_state {
AuthState::Authenticating { request_seq, .. } => *request_seq,
_ => unreachable!(),
};
// The listing lands: rows appear, the probed base is adopted.
let adopted = CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
let listed = kigi_shell::agent::custom_providers::LoginFetch {
provider: adopted,
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
};
let effects = dispatch(
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
request_seq: seq,
result: Ok(listed),
}),
&mut app,
);
assert!(effects.is_empty());
let state = app.custom_select.as_ref().expect("selection lives");
assert_eq!(state.provider.base_url, "https://h.example/v1");
assert_eq!(state.rows.len(), 2);
assert!(!state.rows[0].selected);
// An empty selection refuses to finish once the listing resolved.
assert!(dispatch(Action::SubmitCustomProviderSelection, &mut app).is_empty());
assert!(
app.custom_select
.as_ref()
.and_then(|s| s.error.as_deref())
.is_some()
);
// A late manual id typed before the fetch survives the fill.
let state = app.custom_select.as_mut().expect("selection lives");
state.add_manual("claude-opus-5-5[1m]");
assert_eq!(state.rows.len(), 3);
assert!(state.rows[2].manual && state.rows[2].selected);
// Select m2 as well, then finish.
let state = app.custom_select.as_mut().expect("selection lives");
state.highlight = 1;
state.toggle_highlighted();
let effects = dispatch(Action::SubmitCustomProviderSelection, &mut app);
match effects.as_slice() {
[
Effect::PersistCustomProviderAndAuthenticate {
request_seq,
provider,
key,
models,
},
] => {
assert_eq!(*request_seq, seq);
assert_eq!(provider.base_url, "https://h.example/v1");
assert_eq!(key, "sk-gw");
assert_eq!(models, &["m2".to_owned(), "claude-opus-5-5[1m]".to_owned()]);
}
other => panic!("expected the persist effect, got {other:?}"),
}
assert!(app.custom_select.is_none());
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::Pending,
..
}
));
assert_eq!(
app.auth_in_flight_method.as_ref().map(|m| m.0.as_ref()),
Some("custom:gw")
);
assert!(app.auth_code_input.is_empty());
}
/// Finishing while the listing is in flight completes when it lands.
#[test]
fn custom_provider_selection_finish_waits_for_the_fetch() {
use kigi_shell::models::custom::{CustomApi, CustomProvider};
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
&mut app,
);
dispatch(
Action::SubmitCustomProviderInput("https://h.example".into()),
&mut app,
);
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
let seq = match &app.auth_state {
AuthState::Authenticating { request_seq, .. } => *request_seq,
_ => unreachable!(),
};
let state = app.custom_select.as_mut().expect("selection lives");
state.add_manual("m1");
// Enter with an empty box while the fetch runs: no persist yet.
assert!(dispatch(Action::SubmitCustomProviderSelection, &mut app).is_empty());
assert!(app.custom_select.as_ref().expect("lives").finish_requested);
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::CustomProviderSelect,
..
}
));
// The listing lands with an adopted /v1 base; the finish fires now.
let adopted = CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
let effects = dispatch(
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
request_seq: seq,
result: Ok(kigi_shell::agent::custom_providers::LoginFetch {
provider: adopted,
model_ids: vec!["m2".to_owned()],
}),
}),
&mut app,
);
match effects.as_slice() {
[
Effect::PersistCustomProviderAndAuthenticate {
provider, models, ..
},
] => {
assert_eq!(provider.base_url, "https://h.example/v1");
assert_eq!(models, &["m1".to_owned()]);
}
other => panic!("expected the persist effect, got {other:?}"),
}
assert!(app.custom_select.is_none());
}
/// A failed listing keeps the manual input usable and shows the reason.
#[test]
fn custom_provider_selection_survives_a_failed_fetch() {
use kigi_shell::models::custom::CustomApi;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
&mut app,
);
dispatch(
Action::SubmitCustomProviderInput("https://h.example/v1".into()),
&mut app,
);
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
let seq = match &app.auth_state {
AuthState::Authenticating { request_seq, .. } => *request_seq,
_ => unreachable!(),
};
let effects = dispatch(
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
request_seq: seq,
result: Err("https://h.example/v1/models answered HTTP 404".to_owned()),
}),
&mut app,
);
assert!(effects.is_empty());
let state = app.custom_select.as_ref().expect("selection lives");
assert!(matches!(
&state.fetch,
crate::app::custom_entry::SelectFetch::Failed(reason) if reason.contains("404")
));
// A stale result for an older request never clobbers the screen.
let effects = dispatch(
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
request_seq: seq + 99,
result: Ok(kigi_shell::agent::custom_providers::LoginFetch {
provider: state.provider.clone(),
model_ids: vec!["stale".to_owned()],
}),
}),
&mut app,
);
assert!(effects.is_empty());
assert!(
app.custom_select
.as_ref()
.expect("selection lives")
.rows
.is_empty()
);
// Manual add still finishes the flow.
let state = app.custom_select.as_mut().expect("selection lives");
state.add_manual("m1");
let effects = dispatch(Action::SubmitCustomProviderSelection, &mut app);
assert!(matches!(
effects.as_slice(),
[Effect::PersistCustomProviderAndAuthenticate { models, .. }] if models == &["m1".to_owned()]
));
}
/// A refused field keeps the user on it.
@@ -127,6 +127,7 @@ fn test_app() -> AppView {
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
custom_entry: Default::default(),
custom_select: None,
next_auth_request_seq: 1,
deferred_startup: Default::default(),
auth_use_oauth: false,
+22 -3
View File
@@ -1915,13 +1915,15 @@ pub(crate) fn execute(
request_seq,
provider,
key,
models,
} => {
let tx = acp_tx.clone();
let abort_handle = tasks.spawn(async move {
// Persist first so authenticate finds the definition and key.
if let Err(e) =
kigi_shell::agent::custom_providers::save_custom_provider(&provider, &key)
.await
if let Err(e) = kigi_shell::agent::custom_providers::save_custom_provider(
&provider, &key, &models,
)
.await
{
let error = format!("Couldn't save the provider: {e:#}");
ulog::error(
@@ -1940,6 +1942,23 @@ pub(crate) fn execute(
});
meta.auth_abort_handle = Some((request_seq, abort_handle));
}
Effect::FetchCustomProviderModels {
request_seq,
provider,
key,
} => {
tasks.spawn(async move {
let result = tokio::task::spawn_blocking(move || {
kigi_shell::agent::custom_providers::fetch_listing_for_login(&provider, &key)
})
.await
.unwrap_or_else(|e| Err(format!("listing task failed: {e}")));
TaskResult::CustomProviderModelsListed {
request_seq,
result,
}
});
}
Effect::SubmitAuthCode { request_seq, code } => {
let tx = acp_tx.clone();
tasks
@@ -462,6 +462,7 @@ pub struct WelcomeRenderParams<'a> {
pub login_label: Option<&'a str>,
pub auth_code_input: &'a str,
pub custom_entry: &'a crate::app::custom_entry::CustomEntryDraft,
pub custom_select: Option<&'a crate::app::custom_entry::CustomSelectState>,
pub clipboard_copied: bool,
pub show_raw_url: bool,
pub tip: Option<&'a str>,
@@ -605,6 +606,7 @@ pub fn render_welcome(
*mode,
params.auth_code_input,
params.custom_entry,
params.custom_select,
params.clipboard_copied,
params.show_raw_url,
);
@@ -1179,6 +1181,7 @@ fn render_welcome_authenticating(
mode: AuthMode,
auth_code_input: &str,
custom_entry: &crate::app::custom_entry::CustomEntryDraft,
custom_select: Option<&crate::app::custom_entry::CustomSelectState>,
clipboard_copied: bool,
show_raw_url: bool,
) -> (Option<Rect>, Option<Rect>) {
@@ -1336,6 +1339,21 @@ fn render_welcome_authenticating(
(None, None)
}
AuthMode::CustomProviderSelect => {
if let Some(select) = custom_select {
render_custom_select(
content_area,
buf,
theme,
top_pad,
logo_line_count,
select,
auth_code_input,
);
}
(None, None)
}
AuthMode::Command => render_browser_status_arm(
content_area,
buf,
@@ -2034,6 +2052,182 @@ fn render_entry_box(
Paragraph::new(hints).render(hint_area, buf);
}
/// The custom provider model-selection screen: fetched rows + manual input.
fn render_custom_select(
content_area: Rect,
buf: &mut Buffer,
theme: &Theme,
top_pad: u16,
logo_line_count: u16,
select: &crate::app::custom_entry::CustomSelectState,
input: &str,
) {
use crate::app::custom_entry::SelectFetch;
let h_pad: u16 = content_area.width / 8;
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
let instruction = format!(
"Select the models to add for {} ({})",
select.provider.name, select.provider.base_url
);
let status: Option<(String, Style)> = match &select.fetch {
SelectFetch::InFlight if select.finish_requested => Some((
"Fetching models... finishing when the list lands".to_owned(),
Style::default().fg(theme.gray),
)),
SelectFetch::InFlight => Some((
"Fetching models...".to_owned(),
Style::default().fg(theme.gray),
)),
SelectFetch::Listed if select.rows.is_empty() => Some((
"The provider served no models; add one below".to_owned(),
Style::default().fg(theme.warning),
)),
SelectFetch::Listed => None,
SelectFetch::Failed(reason) => Some((
format!("Fetch failed: {reason}"),
Style::default().fg(theme.warning),
)),
};
// Word-wrap-aware rows; byte math undercounts and clips the failure reason.
let rows_of = |text: &str| -> u16 {
crate::render::wrapping::word_wrap_line(
&Line::from(Span::raw(text.to_owned())),
inner_width as usize,
)
.len() as u16
};
let msg_height = rows_of(&instruction)
+ status.as_ref().map_or(0, |(s, _)| rows_of(s))
+ select.error.as_deref().map_or(0, rows_of);
let [
_,
logo_area,
_,
msg_area,
_,
rows_area,
_,
prompt_area,
_,
hint_area,
_,
] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_line_count),
Constraint::Length(1),
// Min: spare rows land here, so a mis-measured wrap never clips.
Constraint::Min(msg_height),
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(content_area);
render_logo(logo_area, buf, theme, content_area.height);
let mut lines = vec![
Line::from(Span::styled(
instruction,
Style::default().fg(theme.gray_bright),
))
.alignment(Alignment::Center),
];
if let Some((text, style)) = status {
lines.push(Line::from(Span::styled(text, style)).alignment(Alignment::Center));
}
if let Some(error) = select.error.as_deref() {
lines.push(
Line::from(Span::styled(error, Style::default().fg(theme.warning)))
.alignment(Alignment::Center),
);
}
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default().padding(Padding::horizontal(h_pad)))
.render(msg_area, buf);
let visible = rows_area.height as usize;
let start = select
.highlight
.saturating_add(1)
.saturating_sub(visible)
.min(select.rows.len().saturating_sub(visible));
let mut row_lines = Vec::new();
for (idx, row) in select.rows.iter().enumerate().skip(start).take(visible) {
let highlighted = idx == select.highlight;
let check = if row.selected { "[x]" } else { "[ ]" };
let suffix = if row.manual { " (manual)" } else { "" };
let style = if highlighted {
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.gray_bright)
};
let marker = if highlighted { "› " } else { " " };
row_lines.push(Line::from(Span::styled(
format!("{marker}{check} {}{suffix}", row.id),
style,
)));
}
Paragraph::new(row_lines)
.block(Block::default().padding(Padding::horizontal(h_pad)))
.render(rows_area, buf);
let [_, prompt_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_area.width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(prompt_area);
render_auth_input_box(
prompt_centered,
buf,
theme,
input,
"Type a model id and press enter to add",
false,
);
let hints = Line::from(vec![
Span::styled(
"↑↓",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" navigate ", Style::default().fg(theme.gray)),
Span::styled(
"space",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" toggle ", Style::default().fg(theme.gray)),
Span::styled(
"enter",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" add model / done ", Style::default().fg(theme.gray)),
Span::styled(
"esc",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" back", Style::default().fg(theme.gray)),
])
.alignment(Alignment::Center);
Paragraph::new(hints).render(hint_area, buf);
}
/// Render the auth token input box (loopback mode).
fn render_auth_input_box(
area: Rect,
@@ -2197,6 +2391,7 @@ mod tests {
login_label: None,
auth_code_input: "",
custom_entry: &EMPTY_CUSTOM_ENTRY,
custom_select: None,
clipboard_copied: false,
show_raw_url: false,
tip: None,
@@ -2409,12 +2604,87 @@ mod tests {
AuthMode::CustomProviderEntry(step),
input,
draft,
None,
false,
false,
);
buffer_text(&buf)
}
fn render_custom_select(
select: &crate::app::custom_entry::CustomSelectState,
input: &str,
) -> String {
let area = Rect::new(0, 0, 100, 40);
let mut buf = Buffer::empty(area);
render_welcome_authenticating(
area,
&mut buf,
&Theme::current(),
logo_line_count(area.height),
None,
AuthMode::CustomProviderSelect,
input,
&crate::app::custom_entry::CustomEntryDraft::new(),
Some(select),
false,
false,
);
buffer_text(&buf)
}
/// The selection screen lists rows, status, and the manual input together.
#[test]
fn custom_select_arm_lists_rows_status_and_input() {
use crate::app::custom_entry::CustomSelectState;
use kigi_shell::models::custom::{CustomApi, CustomProvider};
let provider =
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
let mut select = CustomSelectState::new(provider, "sk".to_owned());
let fetching = render_custom_select(&select, "");
assert!(fetching.contains("Fetching models"), "{fetching}");
assert!(fetching.contains("add model / done"), "{fetching}");
select.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
provider: select.provider.clone(),
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
});
select.toggle_highlighted();
select.add_manual("claude-opus-5-5[1m]");
let listed = render_custom_select(&select, "typed-so-far");
assert!(listed.contains("[x] m1"), "{listed}");
assert!(listed.contains("[ ] m2"), "{listed}");
assert!(listed.contains("claude-opus-5-5[1m] (manual)"), "{listed}");
assert!(listed.contains("typed-so-far"), "{listed}");
select.fail_fetch("boom".to_owned());
let failed = render_custom_select(&select, "");
assert!(failed.contains("Fetch failed: boom"), "{failed}");
// A two-hop failure reason must render whole, not clip at the wrap.
let reason = "https://h.example/models answered HTTP 404; https://h.example/v1/models also failed: https://h.example/v1/models answered HTTP 500";
select.fail_fetch(reason.to_owned());
let text = render_custom_select(&select, "");
assert!(text.contains("Fetch failed:"), "{text}");
assert!(text.contains("HTTP 500"), "{text}");
}
/// An empty selection refuses once the fetch resolved.
#[test]
fn custom_select_arm_shows_the_finish_refusal() {
use crate::app::custom_entry::CustomSelectState;
use kigi_shell::models::custom::{CustomApi, CustomProvider};
let provider =
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
let mut select = CustomSelectState::new(provider, "sk".to_owned());
select.fail_fetch("boom".to_owned());
assert!(select.try_finish().is_none());
let text = render_custom_select(&select, "");
assert!(text.contains("Select at least one model"), "{text}");
}
/// Only the key step masks its input.
#[test]
fn custom_entry_arm_masks_only_the_key() {
@@ -2479,6 +2749,7 @@ mod tests {
// auth_code_input
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
// clipboard_copied
false,
// show_raw_url
@@ -3147,6 +3418,7 @@ mod tests {
// auth_code_input — unused in device mode
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
// clipboard_copied
false,
// show_raw_url
@@ -3204,6 +3476,7 @@ mod tests {
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
false,
// show_raw_url
true,
@@ -3232,6 +3505,7 @@ mod tests {
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
false,
// show_raw_url
true,
@@ -3271,6 +3545,7 @@ mod tests {
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
false,
// show_raw_url
true,
@@ -3313,6 +3588,7 @@ mod tests {
// auth_code_input — unused
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
// clipboard_copied
false,
// show_raw_url