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

@@ -570,6 +570,10 @@ pub enum Action {
/// under the platform-id scope, then authenticate with the platform's
/// method id. The key must never be logged.
SubmitPlatformApiKey(String),
/// A custom provider row was picked: start the URL, name, key steps.
BeginCustomProviderEntry(kigi_shell::models::custom::CustomApi),
/// Enter in a custom provider step, with the trimmed input.
SubmitCustomProviderInput(String),
/// Copy the auth URL to the clipboard during authentication.
CopyAuthUrl,
/// Show the raw auth URL with mouse capture disabled for manual copy.
@@ -1614,6 +1618,13 @@ pub enum Effect {
target: crate::app::app_view::PlatformLogin,
key: String,
},
/// Save a custom provider (config.toml and auth.json), then send
/// AuthenticateRequest with `custom:<name>`. SECURITY: never log the key.
PersistCustomProviderAndAuthenticate {
request_seq: u64,
provider: kigi_shell::models::custom::CustomProvider,
key: String,
},
/// Fetch MCP server list from the shell (kigi/mcp/list).
FetchMcpsList {
agent_id: AgentId,
+55 -8
View File
@@ -262,6 +262,8 @@ pub enum AuthMode {
/// Open-platform API-key entry: paste box for a Moonshot key selected
/// from the welcome login picker. Esc returns to the picker (no quit).
ApiKeyEntry(PlatformLogin),
/// Custom provider login: one field per step; Esc returns to the picker.
CustomProviderEntry(crate::app::custom_entry::CustomEntryStep),
}
/// API-key platform login target, selected from the welcome picker. Wraps a
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
@@ -310,6 +312,11 @@ pub enum PendingMenuItem {
target: PlatformLogin,
label: String,
},
/// Custom provider login for one wire.
CustomProvider {
api: kigi_shell::models::custom::CustomApi,
label: String,
},
Quit,
}
impl PendingMenuItem {
@@ -324,7 +331,9 @@ impl PendingMenuItem {
}
pub fn label(&self) -> &str {
match self {
Self::Login { label, .. } | Self::ApiKey { label, .. } => label,
Self::Login { label, .. }
| Self::ApiKey { label, .. }
| Self::CustomProvider { label, .. } => label,
Self::Quit => "Quit",
}
}
@@ -366,7 +375,12 @@ pub fn pending_menu_items(
use kigi_shell::agent::auth_method::AuthMethodKind;
let mut items: Vec<PendingMenuItem> = Vec::new();
for method in auth_methods {
if let Some(target) = PlatformLogin::from_method_id(method.id()) {
if let Some(api) = kigi_shell::agent::custom_providers::picker_api(method.id().0.as_ref()) {
items.push(PendingMenuItem::CustomProvider {
api,
label: method.name().to_string(),
});
} else if let Some(target) = PlatformLogin::from_method_id(method.id()) {
items.push(PendingMenuItem::ApiKey {
target,
label: method.name().to_string(),
@@ -908,6 +922,8 @@ pub struct AppView {
pub auth_start_mode: AuthMode,
/// Text buffer for manual auth token paste (loopback mode).
pub auth_code_input: String,
/// Values collected by the custom provider login steps.
pub custom_entry: crate::app::custom_entry::CustomEntryDraft,
/// Monotonically increasing sequence number for auth requests.
pub next_auth_request_seq: u64,
/// Every session/chat/worktree/prompt action deferred behind startup gates.
@@ -1111,6 +1127,7 @@ impl 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,
@@ -2648,7 +2665,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
return InputOutcome::Unchanged;
}
AuthState::Authenticating {
mode: AuthMode::ApiKeyEntry(_),
mode: mode @ (AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_)),
..
} => {
// Esc cancels BACK TO THE PICKER (unlike the OAuth flows,
@@ -2664,6 +2681,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
}
if key!(Enter).matches(key) {
let trimmed = ctx.auth_code_input.trim().to_string();
if matches!(mode, AuthMode::CustomProviderEntry(_)) {
return InputOutcome::Action(Action::SubmitCustomProviderInput(trimmed));
}
if !trimmed.is_empty() {
return InputOutcome::Action(Action::SubmitPlatformApiKey(trimmed));
}
@@ -2726,7 +2746,8 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
return InputOutcome::ActionThenForward(Action::NewSession);
}
AuthState::Authenticating {
mode: AuthMode::Loopback | AuthMode::ApiKeyEntry(_),
mode:
AuthMode::Loopback | AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
..
} => {
let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
@@ -2889,6 +2910,9 @@ fn dispatch_pending_menu_action(
Some(PendingMenuItem::ApiKey { target, .. }) => {
InputOutcome::Action(Action::BeginPlatformKeyEntry(*target))
}
Some(PendingMenuItem::CustomProvider { api, .. }) => {
InputOutcome::Action(Action::BeginCustomProviderEntry(*api))
}
Some(PendingMenuItem::Quit) if mid_session => InputOutcome::Action(Action::CancelLogin),
Some(PendingMenuItem::Quit) => InputOutcome::Action(Action::Quit),
None => InputOutcome::Unchanged,
@@ -3223,6 +3247,7 @@ impl AppView {
auth_methods: &self.auth_methods,
login_label: self.login_label.as_deref(),
auth_code_input: &self.auth_code_input,
custom_entry: &self.custom_entry,
clipboard_copied: self.auth_clipboard_copied,
show_raw_url: self.auth_show_raw_url,
tip,
@@ -4358,6 +4383,7 @@ pub(crate) mod tests {
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,
@@ -7005,7 +7031,7 @@ pub(crate) mod tests {
#[test]
fn pending_menu_items_lists_interactive_methods_plus_quit() {
let items = pending_menu_items(&fresh_user_auth_methods(), None);
assert_eq!(items.len(), 30, "29 login rows + Quit, got {items:?}");
assert_eq!(items.len(), 32, "31 login rows + Quit, got {items:?}");
assert_login_row(&items[0], "kimi-code", "Kimi Code (OAuth)");
assert_login_row(&items[1], "xai-grok", "xAI Grok (subscription) (OAuth)");
assert_login_row(
@@ -7192,7 +7218,28 @@ pub(crate) mod tests {
label: "MiniMax China (API key)".into(),
}
);
assert_eq!(items[29], PendingMenuItem::Quit);
assert_eq!(
items[29],
PendingMenuItem::CustomProvider {
api: kigi_shell::models::custom::CustomApi::OpenAi,
label: "Custom provider (OpenAI compatible)".into(),
}
);
assert_eq!(
items[30],
PendingMenuItem::CustomProvider {
api: kigi_shell::models::custom::CustomApi::Anthropic,
label: "Custom provider (Anthropic compatible)".into(),
}
);
assert_eq!(items[31], PendingMenuItem::Quit);
assert!(!items[29].connected(&fresh_user_auth_methods()));
assert!(matches!(
dispatch_pending_menu_action(&items, 30, false),
InputOutcome::Action(Action::BeginCustomProviderEntry(
kigi_shell::models::custom::CustomApi::Anthropic
))
));
// The non-interactive methods must never appear as rows.
let byok = kigi_shell::agent::auth_method::build_auth_methods(
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
@@ -7203,8 +7250,8 @@ pub(crate) mod tests {
);
assert_eq!(
pending_menu_items(&byok.methods, None).len(),
30,
"xai.api_key / cached_token must not add rows (29 login rows + Quit)"
32,
"xai.api_key / cached_token must not add rows (31 login rows + Quit)"
);
}
/// Startup lands on the picker only when there is a real choice: the
@@ -0,0 +1,239 @@
//! The base URL, name, and key steps of the custom provider login.
use kigi_shell::models::custom::{
CustomApi, CustomProvider, default_name, normalize_base_url, validate_name,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CustomEntryField {
BaseUrl,
Name,
Key,
}
/// Which field the input box holds, for which wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CustomEntryStep {
pub api: CustomApi,
pub field: CustomEntryField,
}
impl CustomEntryStep {
pub fn first(api: CustomApi) -> Self {
Self {
api,
field: CustomEntryField::BaseUrl,
}
}
/// Only the key is a secret; URL and name show as typed.
pub fn masks_input(self) -> bool {
self.field == CustomEntryField::Key
}
pub fn placeholder(self) -> &'static str {
match self.field {
CustomEntryField::BaseUrl => "https://host/v1",
CustomEntryField::Name => "provider name",
CustomEntryField::Key => "Paste your API key here...",
}
}
}
/// What one Enter press did.
#[derive(Debug, PartialEq, Eq)]
pub enum CustomEntryOutcome {
/// The input stays; `CustomEntryDraft::error` says why, if it does.
Stay,
/// Move to the next field with this text in the box.
Next {
field: CustomEntryField,
prefill: String,
},
/// Every field is valid.
Done {
provider: CustomProvider,
key: String,
},
}
/// Values collected so far, and the last refusal.
#[derive(Debug, Default, Clone)]
pub struct CustomEntryDraft {
base_url: String,
name: String,
pub error: Option<String>,
}
impl CustomEntryDraft {
pub const fn new() -> Self {
Self {
base_url: String::new(),
name: String::new(),
error: None,
}
}
pub fn clear(&mut self) {
*self = Self::new();
}
pub fn instruction(&self, step: CustomEntryStep) -> String {
match step.field {
CustomEntryField::BaseUrl => format!(
"Base URL of the {} API, for example https://host/v1",
step.api.label()
),
CustomEntryField::Name => {
"Name for this provider. Its models appear as name/model".to_owned()
}
CustomEntryField::Key => format!(
"API key for {}. A local server that ignores auth takes any value",
self.name
),
}
}
pub fn submit(&mut self, step: CustomEntryStep, input: &str) -> CustomEntryOutcome {
let input = input.trim();
if input.is_empty() {
self.error = None;
return CustomEntryOutcome::Stay;
}
self.error = None;
match step.field {
CustomEntryField::BaseUrl => match normalize_base_url(input) {
Ok(url) => {
let prefill = default_name(&url);
self.base_url = url;
CustomEntryOutcome::Next {
field: CustomEntryField::Name,
prefill,
}
}
Err(e) => self.refuse(e),
},
CustomEntryField::Name => match validate_name(input) {
Ok(()) => {
self.name = input.to_owned();
CustomEntryOutcome::Next {
field: CustomEntryField::Key,
prefill: String::new(),
}
}
Err(e) => self.refuse(e),
},
CustomEntryField::Key => {
match CustomProvider::new(&self.name, step.api, &self.base_url) {
Ok(provider) => CustomEntryOutcome::Done {
provider,
key: input.to_owned(),
},
Err(e) => self.refuse(e),
}
}
}
}
fn refuse(&mut self, reason: impl std::fmt::Display) -> CustomEntryOutcome {
self.error = Some(reason.to_string());
CustomEntryOutcome::Stay
}
}
#[cfg(test)]
mod tests {
use super::*;
fn step(field: CustomEntryField) -> CustomEntryStep {
CustomEntryStep {
api: CustomApi::Anthropic,
field,
}
}
#[test]
fn three_valid_steps_build_the_provider() {
let mut draft = CustomEntryDraft::default();
let next = draft.submit(
step(CustomEntryField::BaseUrl),
" https://api.gw.example/v1/ ",
);
assert_eq!(
next,
CustomEntryOutcome::Next {
field: CustomEntryField::Name,
prefill: "gw-example".to_owned()
}
);
let next = draft.submit(step(CustomEntryField::Name), "gw");
assert_eq!(
next,
CustomEntryOutcome::Next {
field: CustomEntryField::Key,
prefill: String::new()
}
);
assert!(
draft
.instruction(step(CustomEntryField::Key))
.contains("API key for gw")
);
let done = draft.submit(step(CustomEntryField::Key), " sk-1 ");
let CustomEntryOutcome::Done { provider, key } = done else {
panic!("expected Done, got {done:?}");
};
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-1");
assert!(draft.error.is_none());
}
#[test]
fn bad_input_stays_on_the_field_with_a_reason() {
let mut draft = CustomEntryDraft::default();
assert_eq!(
draft.submit(step(CustomEntryField::BaseUrl), "host.example"),
CustomEntryOutcome::Stay
);
assert!(draft.error.as_deref().is_some_and(|e| e.contains("http")));
draft.submit(step(CustomEntryField::BaseUrl), "https://h.example/v1");
assert_eq!(
draft.submit(step(CustomEntryField::Name), "OpenAI"),
CustomEntryOutcome::Stay
);
assert!(draft.error.is_some());
assert_eq!(
draft.submit(step(CustomEntryField::Name), "openai"),
CustomEntryOutcome::Stay
);
assert!(
draft
.error
.as_deref()
.is_some_and(|e| e.contains("built-in"))
);
}
#[test]
fn empty_input_stays_and_clears_the_last_error() {
let mut draft = CustomEntryDraft::default();
draft.submit(step(CustomEntryField::BaseUrl), "nope");
assert!(draft.error.is_some());
assert_eq!(
draft.submit(step(CustomEntryField::BaseUrl), " "),
CustomEntryOutcome::Stay
);
assert!(draft.error.is_none());
}
#[test]
fn only_the_key_is_masked() {
assert!(step(CustomEntryField::Key).masks_input());
assert!(!step(CustomEntryField::BaseUrl).masks_input());
assert!(!step(CustomEntryField::Name).masks_input());
}
}
@@ -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,
@@ -1911,6 +1911,35 @@ pub(crate) fn execute(
});
meta.auth_abort_handle = Some((request_seq, abort_handle));
}
Effect::PersistCustomProviderAndAuthenticate {
request_seq,
provider,
key,
} => {
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
{
let error = format!("Couldn't save the provider: {e:#}");
ulog::error(
"custom provider persist failed",
None,
Some(serde_json::json!({ "error" : & error })),
);
return TaskResult::AuthFailed {
request_seq,
error,
};
}
let method_id =
kigi_shell::agent::custom_providers::login_method_id(&provider.name);
send_authenticate(&tx, request_seq, method_id, false, false).await
});
meta.auth_abort_handle = Some((request_seq, abort_handle));
}
Effect::SubmitAuthCode { request_seq, code } => {
let tx = acp_tx.clone();
tasks
+1
View File
@@ -15,6 +15,7 @@ pub mod agent_view;
pub mod app_view;
pub mod bundle;
pub mod cli;
pub mod custom_entry;
pub use crate::link_opener;
/// Off-thread full-file syntax highlight upgrade for edit diffs.
pub mod edit_highlight_worker;
+208 -67
View File
@@ -461,6 +461,7 @@ pub struct WelcomeRenderParams<'a> {
pub auth_methods: &'a [acp::AuthMethod],
pub login_label: Option<&'a str>,
pub auth_code_input: &'a str,
pub custom_entry: &'a crate::app::custom_entry::CustomEntryDraft,
pub clipboard_copied: bool,
pub show_raw_url: bool,
pub tip: Option<&'a str>,
@@ -603,6 +604,7 @@ pub fn render_welcome(
auth_url.as_deref(),
*mode,
params.auth_code_input,
params.custom_entry,
params.clipboard_copied,
params.show_raw_url,
);
@@ -1176,6 +1178,7 @@ fn render_welcome_authenticating(
auth_url: Option<&str>,
mode: AuthMode,
auth_code_input: &str,
custom_entry: &crate::app::custom_entry::CustomEntryDraft,
clipboard_copied: bool,
show_raw_url: bool,
) -> (Option<Rect>, Option<Rect>) {
@@ -1270,6 +1273,7 @@ fn render_welcome_authenticating(
theme,
auth_code_input,
"Paste your token here...",
true,
);
// Hints
@@ -1290,81 +1294,45 @@ fn render_welcome_authenticating(
}
AuthMode::ApiKeyEntry(target) => {
// Platform API-key paste box: instruction + input + hints. No
// auth-URL machinery — the key comes from the platform console.
let h_pad: u16 = content_area.width / 6;
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
// Platform API-key paste box: the key comes from the platform console.
let instruction = format!(
"Paste your {} API key (from {})",
target.vendor(),
target.console_host()
);
let msg_height = (instruction.len() as u16).div_ceil(inner_width);
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_line_count),
// gap
Constraint::Length(1),
// instruction
Constraint::Length(msg_height),
// gap
Constraint::Min(1),
// prompt box
Constraint::Length(5),
// gap
Constraint::Length(1),
// hints
Constraint::Length(1),
Constraint::Min(0),
])
.areas(content_area);
render_logo(logo_area, buf, theme, content_area.height);
let msg = Line::from(Span::styled(
instruction,
Style::default().fg(theme.gray_bright),
))
.alignment(Alignment::Center);
Paragraph::new(msg)
.wrap(Wrap { trim: false })
.block(Block::default().padding(Padding::horizontal(h_pad)))
.render(msg_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,
render_entry_box(
content_area,
buf,
theme,
auth_code_input,
"Paste your API key here...",
top_pad,
logo_line_count,
&EntryBox {
instruction: &instruction,
error: None,
input: auth_code_input,
placeholder: "Paste your API key here...",
masked: true,
},
);
(None, None)
}
let hints = Line::from(vec![
Span::styled(
"enter",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" submit ", 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);
AuthMode::CustomProviderEntry(step) => {
let instruction = custom_entry.instruction(step);
render_entry_box(
content_area,
buf,
theme,
top_pad,
logo_line_count,
&EntryBox {
instruction: &instruction,
error: custom_entry.error.as_deref(),
input: auth_code_input,
placeholder: step.placeholder(),
masked: step.masks_input(),
},
);
(None, None)
}
@@ -1971,12 +1939,109 @@ pub(crate) fn render_session_picker(
}
/// Render the auth token input box (loopback mode).
/// One text field under the logo: instruction, optional error, input box, hints.
struct EntryBox<'a> {
instruction: &'a str,
error: Option<&'a str>,
input: &'a str,
placeholder: &'a str,
masked: bool,
}
fn render_entry_box(
content_area: Rect,
buf: &mut Buffer,
theme: &Theme,
top_pad: u16,
logo_line_count: u16,
entry: &EntryBox<'_>,
) {
let h_pad: u16 = content_area.width / 6;
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
let rows = |text: &str| (text.len() as u16).div_ceil(inner_width);
let msg_height = rows(entry.instruction) + entry.error.map_or(0, rows);
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_line_count),
// gap
Constraint::Length(1),
// instruction
Constraint::Length(msg_height),
// gap
Constraint::Min(1),
// prompt box
Constraint::Length(5),
// gap
Constraint::Length(1),
// hints
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(
entry.instruction,
Style::default().fg(theme.gray_bright),
))
.alignment(Alignment::Center),
];
if let Some(error) = entry.error {
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 [_, 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,
entry.input,
entry.placeholder,
entry.masked,
);
let hints = Line::from(vec![
Span::styled(
"enter",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" submit ", 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);
}
fn render_auth_input_box(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
input: &str,
placeholder: &str,
masked: bool,
) {
let prompt_block = Block::default()
.borders(Borders::ALL)
@@ -1993,8 +2058,10 @@ fn render_auth_input_box(
if inner.height > 0 && inner.width > 2 {
let display = if input.is_empty() {
placeholder.to_string()
} else {
} else if masked {
mask_auth_token_for_display(input)
} else {
input.to_string()
};
let style = if input.is_empty() {
@@ -2078,6 +2145,8 @@ fn mask_auth_token_for_display(input: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
static EMPTY_CUSTOM_ENTRY: crate::app::custom_entry::CustomEntryDraft =
crate::app::custom_entry::CustomEntryDraft::new();
use crate::app::app_view::SessionPickerEntry;
use crate::views::picker::PickerState;
use crate::views::session_picker::{build_grouped_picker_entries, build_session_entry_data};
@@ -2127,6 +2196,7 @@ mod tests {
auth_methods: &[],
login_label: None,
auth_code_input: "",
custom_entry: &EMPTY_CUSTOM_ENTRY,
clipboard_copied: false,
show_raw_url: false,
tip: None,
@@ -2325,6 +2395,71 @@ mod tests {
/// The Moonshot API-key entry arm renders the platform copy, the paste
/// box, and the esc-back hint — and no OAuth-URL affordances.
fn render_custom_entry(
step: crate::app::custom_entry::CustomEntryStep,
draft: &crate::app::custom_entry::CustomEntryDraft,
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::CustomProviderEntry(step),
input,
draft,
false,
false,
);
buffer_text(&buf)
}
/// The URL and name steps show typed text; only the key step masks it.
#[test]
fn custom_entry_arm_masks_only_the_key() {
use crate::app::custom_entry::{CustomEntryDraft, CustomEntryField, CustomEntryStep};
use kigi_shell::models::custom::CustomApi;
let step = |field| CustomEntryStep {
api: CustomApi::OpenAi,
field,
};
let draft = CustomEntryDraft::new();
let url = render_custom_entry(
step(CustomEntryField::BaseUrl),
&draft,
"https://very-long-host.example/v1",
);
assert!(url.contains("https://very-long-host.example/v1"), "{url}");
assert!(url.contains("OpenAI compatible"), "{url}");
let key = render_custom_entry(step(CustomEntryField::Key), &draft, "sk-abcdefghijklmnop");
assert!(
!key.contains("sk-abcdefghij"),
"the key prefix must stay hidden: {key}"
);
assert!(key.contains("mnop"), "{key}");
let empty = render_custom_entry(step(CustomEntryField::Name), &draft, "");
assert!(empty.contains("provider name"), "{empty}");
}
/// A refusal shows under the instruction so the user sees what to fix.
#[test]
fn custom_entry_arm_shows_the_refusal() {
use crate::app::custom_entry::{CustomEntryDraft, CustomEntryStep};
use kigi_shell::models::custom::CustomApi;
let step = CustomEntryStep::first(CustomApi::Anthropic);
let mut draft = CustomEntryDraft::new();
draft.submit(step, "host.example");
let text = render_custom_entry(step, &draft, "host.example");
assert!(text.contains("Anthropic compatible"), "{text}");
assert!(text.contains("must start with http"), "{text}");
}
#[test]
fn api_key_entry_arm_shows_platform_copy_and_paste_box() {
let area = Rect::new(0, 0, 80, 40);
@@ -2343,6 +2478,7 @@ mod tests {
)),
// auth_code_input
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
// clipboard_copied
false,
// show_raw_url
@@ -3010,6 +3146,7 @@ mod tests {
AuthMode::Device,
// auth_code_input — unused in device mode
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
// clipboard_copied
false,
// show_raw_url
@@ -3066,6 +3203,7 @@ mod tests {
Some(url),
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
false,
// show_raw_url
true,
@@ -3093,6 +3231,7 @@ mod tests {
Some(url),
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
false,
// show_raw_url
true,
@@ -3131,6 +3270,7 @@ mod tests {
Some(url),
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
false,
// show_raw_url
true,
@@ -3172,6 +3312,7 @@ mod tests {
AuthMode::Command,
// auth_code_input — unused
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
// clipboard_copied
false,
// show_raw_url