feat(login): custom provider rows, URL / name / key steps, and custom:<name> authenticate

The picker lists Custom provider (OpenAI compatible) and (Anthropic compatible).
The TUI asks for a base URL, a name (default from the host), and a key, saves the
definition and the key, and the agent validates the key against GET {base}/models.
PTY e2e covers both wires against the mock inference server.
This commit is contained in:
雷电芽衣 committed 2026-09-29 16:10:41 -04:00
1 parent eb86d7ec26
commit 791f953c07
18 files changed
+1198 -85

No files matched your search

@@ -8,6 +8,7 @@ use crate::app::actions::{Action, Effect};
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, PlatformLogin};
use crate::app::custom_entry::{CustomEntryOutcome, CustomEntryStep};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
@@ -325,6 +326,73 @@ pub(super) fn dispatch_begin_platform_key_entry(
vec![]
}
/// A custom provider row was selected: open the first step (base URL).
pub(super) fn dispatch_begin_custom_provider_entry(
app: &mut AppView,
api: kigi_shell::models::custom::CustomApi,
) -> Vec<Effect> {
let request_seq = app.next_auth_request_seq;
app.next_auth_request_seq += 1;
app.auth_code_input.clear();
app.custom_entry.clear();
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: AuthMode::CustomProviderEntry(CustomEntryStep::first(api)),
};
vec![]
}
/// Enter in a custom provider step: advance, stay with a reason, or save.
pub(super) fn dispatch_submit_custom_provider_input(
app: &mut AppView,
input: String,
) -> Vec<Effect> {
let (request_seq, step) = match &app.auth_state {
AuthState::Authenticating {
request_seq,
mode: AuthMode::CustomProviderEntry(step),
..
} => (*request_seq, *step),
_ => return vec![],
};
match app.custom_entry.submit(step, &input) {
CustomEntryOutcome::Stay => vec![],
CustomEntryOutcome::Next { field, prefill } => {
app.auth_code_input = prefill;
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: AuthMode::CustomProviderEntry(CustomEntryStep {
api: step.api,
field,
}),
};
vec![]
}
CustomEntryOutcome::Done { provider, key } => {
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
&provider.name,
));
app.custom_entry.clear();
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,
}]
}
}
}
/// 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.
@@ -332,7 +400,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
if !matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::ApiKeyEntry(_),
mode: AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
..
}
) {
@@ -340,6 +408,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.auth_state = AuthState::Pending { error: None };
vec![]
}
@@ -1,8 +1,9 @@
//! Top-level action router: maps actions and action results to handlers.
use super::auth::{
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_platform_api_key, dispatch_switch_account,
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,
};
use super::ctx::{
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
@@ -889,6 +890,10 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::BeginPlatformKeyEntry(target) => dispatch_begin_platform_key_entry(app, target),
Action::CancelPlatformKeyEntry => dispatch_cancel_platform_key_entry(app),
Action::SubmitPlatformApiKey(key) => dispatch_submit_platform_api_key(app, key),
Action::BeginCustomProviderEntry(api) => dispatch_begin_custom_provider_entry(app, api),
Action::SubmitCustomProviderInput(input) => {
dispatch_submit_custom_provider_input(app, input)
}
Action::CopyAuthUrl => {
if let AuthState::Authenticating {
auth_url: Some(url),
@@ -571,3 +571,141 @@ fn auth_complete_marks_the_authenticated_method_connected() {
"other methods must stay unmarked"
);
}
fn custom_field(app: &AppView) -> crate::app::custom_entry::CustomEntryField {
match &app.auth_state {
AuthState::Authenticating {
mode: AuthMode::CustomProviderEntry(step),
..
} => step.field,
other => panic!("expected a custom provider step, got {other:?}"),
}
}
/// The custom provider flow: URL, then name (prefilled from the host), then
/// key; the last Enter emits ONE persist+authenticate effect for the provider.
#[test]
fn custom_provider_steps_end_in_one_persist_effect() {
use crate::app::custom_entry::CustomEntryField;
use kigi_shell::models::custom::CustomApi;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
let effects = dispatch(
Action::BeginCustomProviderEntry(CustomApi::Anthropic),
&mut app,
);
assert!(effects.is_empty());
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
let seq = match &app.auth_state {
AuthState::Authenticating { request_seq, .. } => *request_seq,
_ => unreachable!(),
};
dispatch(
Action::SubmitCustomProviderInput("https://api.gw.example/v1/".into()),
&mut app,
);
assert_eq!(custom_field(&app), CustomEntryField::Name);
assert_eq!(
app.auth_code_input, "gw-example",
"the name is prefilled from the host"
);
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
assert_eq!(custom_field(&app), CustomEntryField::Key);
assert!(app.auth_code_input.is_empty(), "the key box starts empty");
let effects = dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
match effects.as_slice() {
[
Effect::PersistCustomProviderAndAuthenticate {
request_seq,
provider,
key,
},
] => {
assert_eq!(*request_seq, seq);
assert_eq!(provider.name, "gw");
assert_eq!(provider.api, CustomApi::Anthropic);
assert_eq!(provider.base_url, "https://api.gw.example/v1");
assert_eq!(key, "sk-gw");
}
other => panic!("expected exactly the persist effect, got {other:?}"),
}
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
request_seq,
mode: AuthMode::Pending,
..
} if request_seq == seq
));
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());
}
/// A refused field keeps the user on it and does not emit an effect.
#[test]
fn custom_provider_refusals_stay_on_the_field() {
use crate::app::custom_entry::CustomEntryField;
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,
);
assert!(
dispatch(
Action::SubmitCustomProviderInput("host.example".into()),
&mut app
)
.is_empty()
);
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
assert!(app.custom_entry.error.is_some());
dispatch(
Action::SubmitCustomProviderInput("https://h.example/v1".into()),
&mut app,
);
assert!(
dispatch(
Action::SubmitCustomProviderInput("anthropic".into()),
&mut app
)
.is_empty()
);
assert_eq!(custom_field(&app), CustomEntryField::Name);
assert!(app.custom_entry.error.is_some());
}
/// Esc at any step returns to the picker and drops the draft.
#[test]
fn cancel_custom_provider_entry_returns_to_picker() {
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("nope".into()), &mut app);
assert!(app.custom_entry.error.is_some());
let seq_before = app.next_auth_request_seq;
let effects = dispatch(Action::CancelPlatformKeyEntry, &mut app);
assert!(effects.is_empty());
assert!(matches!(app.auth_state, AuthState::Pending { error: None }));
assert!(app.custom_entry.error.is_none());
assert!(app.next_auth_request_seq > seq_before);
}
@@ -126,6 +126,7 @@ fn test_app() -> AppView {
login_method_id: None,
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
custom_entry: Default::default(),
next_auth_request_seq: 1,
deferred_startup: Default::default(),
auth_use_oauth: false,