feat(login): /login opens the provider picker with green connected badges
/login previously fired the resolved method's flow immediately — there was no way to see providers or their status. It now lands on the provider picker; already-connected providers show a green 'connected' badge in the key column. Shell: initialize() probes stored credentials once (primary session flag, auth.json oauth/<provider> scopes, resolved platform keys env>auth.json> config) and stamps _meta.connected on each advertised method (connected_method_ids + stamp_connected_meta, pure and unit-tested). Display state only — never an authorization input. TUI: - PendingMenuItem::connected() reads the badge from method meta; the picker renders it green (accent_success), replacing the shortcut hint. - New Action::OpenLoginPicker: /login shows the picker; mid-session it stashes the view like dispatch_login, and starts no flow by itself. - Mid-session picker: last row reads 'Cancel' and dispatches CancelLogin (clicking it must not exit the app); Esc also returns to the session. - After a successful login, the just-authenticated method is stamped connected in the TUI's advertised-methods copy (auth_in_flight_method → AuthComplete), so a later /login shows the badge without re-initialize. Verified: kigi-shell 5256 + kigi-tui 6870 tests green, clippy clean.
This commit is contained in:
1 parent
1e57cd9225
commit
fc7c2a1b9b
11 files changed
+384
-15
No files matched your search
@@ -64,6 +64,7 @@ pub(super) fn dispatch_switch_account(app: &mut AppView) -> Vec<Effect> {
|
||||
let request_seq = app.next_auth_request_seq;
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.auth_in_flight_method = Some(method_id.clone());
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
@@ -178,6 +179,25 @@ pub(super) fn dispatch_login(app: &mut AppView) -> Vec<Effect> {
|
||||
dispatch_login_with(app, None)
|
||||
}
|
||||
|
||||
/// `/login`: land on the provider picker (welcome `Pending` state) instead
|
||||
/// of auto-starting a flow. Connected providers show their badge; the user
|
||||
/// picks a row, which dispatches `LoginWith` / key entry as usual. From
|
||||
/// inside a session the current view is stashed exactly like
|
||||
/// [`dispatch_login`], so Esc/q (`CancelLogin`) returns to it.
|
||||
pub(super) fn dispatch_open_login_picker(app: &mut AppView) -> Vec<Effect> {
|
||||
if !matches!(app.active_view, ActiveView::Welcome) {
|
||||
app.auth_return_view = Some(app.active_view);
|
||||
show_welcome(app);
|
||||
}
|
||||
// Drop any stale in-flight auth result and open a fresh picker.
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.welcome_menu_index = None;
|
||||
app.welcome_menu_scroll = 0;
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Start an interactive login flow with an explicitly chosen method (a
|
||||
/// provider row on the login picker). `None` keeps the historical behavior:
|
||||
/// re-use the current method or resolve the first interactive one.
|
||||
@@ -232,6 +252,7 @@ pub(super) fn dispatch_login_with(
|
||||
let request_seq = app.next_auth_request_seq;
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.auth_in_flight_method = Some(method_id.clone());
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
@@ -265,6 +286,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.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
|
||||
// stale re-auth prompt from scrollback (on all agents: the login may
|
||||
@@ -345,6 +367,7 @@ pub(super) fn dispatch_submit_platform_api_key(app: &mut AppView, key: String) -
|
||||
if key.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
app.auth_in_flight_method = Some(target.method_id());
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
@@ -358,6 +381,28 @@ pub(super) fn dispatch_submit_platform_api_key(app: &mut AppView, key: String) -
|
||||
}]
|
||||
}
|
||||
|
||||
/// Stamp `_meta.connected` on the advertised method `id` (the TUI-side
|
||||
/// mirror of the shell's initialize-time stamping — see
|
||||
/// `kigi_shell::agent::auth_method::stamp_connected_meta`).
|
||||
fn mark_method_connected(
|
||||
methods: &mut [agent_client_protocol::AuthMethod],
|
||||
id: &agent_client_protocol::AuthMethodId,
|
||||
) {
|
||||
use kigi_shell::agent::auth_method::CONNECTED_META_KEY;
|
||||
for method in methods.iter_mut() {
|
||||
if let agent_client_protocol::AuthMethod::Agent(agent) = method
|
||||
&& agent.id == *id
|
||||
{
|
||||
let mut meta = agent.meta.take().unwrap_or_default();
|
||||
meta.insert(
|
||||
CONNECTED_META_KEY.to_string(),
|
||||
serde_json::Value::Bool(true),
|
||||
);
|
||||
agent.meta = Some(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TaskResult handlers.
|
||||
|
||||
pub(super) fn handle_auth_complete(
|
||||
@@ -378,6 +423,13 @@ pub(super) fn handle_auth_complete(
|
||||
app.apply_auth_meta(&auth_meta);
|
||||
}
|
||||
|
||||
// The method that just authenticated is now connected — stamp the
|
||||
// advertised-methods copy so a later /login picker shows its badge
|
||||
// (initialize-time stamping only covers what was stored at startup).
|
||||
if let Some(id) = app.auth_in_flight_method.take() {
|
||||
mark_method_connected(&mut app.auth_methods, &id);
|
||||
}
|
||||
|
||||
app.auth_state = AuthState::Done;
|
||||
app.auth_show_raw_url = false;
|
||||
app.welcome_prompt_focused = true;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! 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_submit_auth_code,
|
||||
dispatch_submit_platform_api_key, dispatch_switch_account,
|
||||
dispatch_login, dispatch_login_with, dispatch_logout, dispatch_open_login_picker,
|
||||
dispatch_submit_auth_code, dispatch_submit_platform_api_key, dispatch_switch_account,
|
||||
};
|
||||
use super::ctx::{
|
||||
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
|
||||
@@ -877,6 +877,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
}
|
||||
Action::Login => dispatch_login(app),
|
||||
Action::LoginWith(method_id) => dispatch_login_with(app, Some(method_id)),
|
||||
Action::OpenLoginPicker => dispatch_open_login_picker(app),
|
||||
Action::CancelLogin => dispatch_cancel_login(app),
|
||||
Action::SubmitAuthCode(code) => dispatch_submit_auth_code(app, code),
|
||||
Action::BeginPlatformKeyEntry(target) => dispatch_begin_platform_key_entry(app, target),
|
||||
|
||||
@@ -506,3 +506,72 @@ fn login_with_unknown_method_fails_closed() {
|
||||
app.auth_state
|
||||
);
|
||||
}
|
||||
|
||||
/// `/login` opens the provider picker (welcome `Pending`) instead of
|
||||
/// auto-starting a flow — and from inside a session it stashes the view
|
||||
/// (so Esc/q/Cancel return to it) without emitting any auth effect.
|
||||
#[test]
|
||||
fn open_login_picker_shows_picker_and_stashes_view() {
|
||||
let mut app = test_app_with_agent();
|
||||
assert_eq!(app.active_view, ActiveView::Agent(AgentId(0)));
|
||||
|
||||
let effects = dispatch(Action::OpenLoginPicker, &mut app);
|
||||
|
||||
assert!(effects.is_empty(), "the picker itself starts nothing");
|
||||
assert_eq!(app.active_view, ActiveView::Welcome);
|
||||
assert_eq!(app.auth_return_view, Some(ActiveView::Agent(AgentId(0))));
|
||||
assert!(matches!(app.auth_state, AuthState::Pending { error: None }));
|
||||
}
|
||||
|
||||
/// A successful login stamps `_meta.connected` on the method that just
|
||||
/// authenticated, so a later `/login` picker shows its green badge without
|
||||
/// re-initializing the shell.
|
||||
#[test]
|
||||
fn auth_complete_marks_the_authenticated_method_connected() {
|
||||
use crate::app::app_view::pending_menu_items;
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
app.auth_methods = kigi_shell::agent::auth_method::build_auth_methods(
|
||||
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
||||
has_external_api_key: false,
|
||||
has_cached_token: false,
|
||||
login_label: None,
|
||||
},
|
||||
)
|
||||
.methods;
|
||||
|
||||
dispatch(
|
||||
Action::LoginWith(acp::AuthMethodId::new("xai-grok")),
|
||||
&mut app,
|
||||
);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
other => panic!("expected Authenticating, got {other:?}"),
|
||||
};
|
||||
dispatch(
|
||||
Action::TaskComplete(TaskResult::AuthComplete {
|
||||
request_seq: seq,
|
||||
meta: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let items = pending_menu_items(&app.auth_methods, None);
|
||||
let grok = items
|
||||
.iter()
|
||||
.find(|i| i.label().starts_with("xAI Grok"))
|
||||
.expect("grok row");
|
||||
assert!(
|
||||
grok.connected(&app.auth_methods),
|
||||
"the just-authenticated method must show as connected"
|
||||
);
|
||||
let kimi = items
|
||||
.iter()
|
||||
.find(|i| i.label().starts_with("Kimi Code"))
|
||||
.expect("kimi row");
|
||||
assert!(
|
||||
!kimi.connected(&app.auth_methods),
|
||||
"other methods must stay unmarked"
|
||||
);
|
||||
}
|
||||
@@ -141,6 +141,7 @@ fn test_app() -> AppView {
|
||||
welcome_tip_typing_dismissed: false,
|
||||
welcome_menu_index: None,
|
||||
welcome_menu_scroll: 0,
|
||||
auth_in_flight_method: None,
|
||||
welcome_menu_rects: Vec::new(),
|
||||
welcome_import_banner_rect: None,
|
||||
last_mouse_pos: None,
|
||||
|
||||
Reference in new issue
Block a user