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:
2026-07-22 16:14:42 -04:00
parent 1e57cd9225
commit fc7c2a1b9b
11 changed files with 384 additions and 15 deletions
@@ -9,6 +9,11 @@ use crate::theme::Theme;
use super::logo::logo_visual_width;
/// Key-column badge for a login-picker row whose provider already holds a
/// stored credential. Rendered green (content-based restyle, like the
/// import row's `[x]`).
pub(crate) const CONNECTED_BADGE: &str = "connected";
/// Render the welcome menu rows as `label … shortcut`, padded within each row.
///
/// The area is a viewport: when there are more items than rows, the window
@@ -117,8 +122,16 @@ pub fn render_menu(
};
buf.set_span(menu_centered.x, y, &Span::styled(*label, lstyle), label_len);
// Key shortcut flush with the right edge of the menu column.
let kstyle = if is_selected {
// Key shortcut flush with the right edge of the menu column. The
// "connected" badge renders green instead of the shortcut gray.
let kstyle = if *key == CONNECTED_BADGE {
let green = Style::default().fg(theme.accent_success);
if is_selected {
green.bg(theme.bg_highlight)
} else {
green
}
} else if is_selected {
key_selected_style
} else {
key_style
@@ -468,6 +468,10 @@ pub struct WelcomeRenderParams<'a> {
/// scroll viewport; the frame's actual offset comes back in
/// [`WelcomeRenderResult::menu_scroll`]).
pub menu_scroll: usize,
/// True when the login flow was opened from inside a session (`/login`,
/// a 401 re-auth). The picker's last row then reads "Cancel" (return to
/// the session) instead of "Quit" (exit the app).
pub mid_session_login: bool,
pub has_claude_import: bool,
pub mouse_pos: Option<(u16, u16)>,
pub session_picker: Option<&'a [SessionPickerEntry]>,
@@ -534,13 +538,28 @@ pub fn render_welcome(
let mut result = match params.auth_state {
AuthState::Pending { error } => {
// Login picker: one row per interactive method + Quit.
// Login picker: one row per interactive method + Quit. Rows whose
// method already holds a stored credential show the green
// "connected" badge in the key column instead of a shortcut.
let items: Vec<PendingMenuItem> =
pending_menu_items(params.auth_methods, params.login_label);
let menu: Vec<(&str, &str)> = items
.iter()
.enumerate()
.map(|(i, item)| (item.shortcut(i), item.label()))
.map(|(i, item)| {
let key = if item.connected(params.auth_methods) {
menu::CONNECTED_BADGE
} else {
item.shortcut(i)
};
// Mid-session the last row returns to the session; it
// must not read like an app exit.
let label = match item {
PendingMenuItem::Quit if params.mid_session_login => "Cancel",
_ => item.label(),
};
(key, label)
})
.collect();
let msg = error.as_deref().map(|e| (e, theme.accent_error));
let info = PromptInfo {
@@ -2088,6 +2107,7 @@ mod tests {
flags: &[],
selected: None,
menu_scroll: 0,
mid_session_login: false,
has_claude_import: false,
mouse_pos: None,
session_picker,
@@ -2224,6 +2244,44 @@ mod tests {
);
}
/// Rows whose method the shell stamped `_meta.connected` show the
/// "connected" badge in the key column; unconnected rows do not. The
/// mid-session picker's last row reads "Cancel", not "Quit".
#[test]
fn pending_menu_shows_connected_badge_and_mid_session_cancel() {
use kigi_shell::agent::auth_method::{
AuthMethodsBuildInputs, build_auth_methods, stamp_connected_meta,
};
let mut built = build_auth_methods(AuthMethodsBuildInputs {
has_external_api_key: false,
has_cached_token: false,
login_label: None,
});
stamp_connected_meta(
&mut built.methods,
&std::collections::HashSet::from(["kimi-code"]),
);
let auth = AuthState::Pending { error: None };
let trust = TrustState::Done;
let mut params = render_params(&auth, &trust, None);
params.auth_methods = &built.methods;
let text = render_done_text_h(&params, 40);
assert_eq!(
text.matches("connected").count(),
1,
"exactly the stamped method shows the badge:\n{text}"
);
assert!(!text.contains("Cancel"), "startup picker keeps Quit");
// Mid-session (/login over a live session): last row reads Cancel.
params.mid_session_login = true;
params.selected = Some(pending_menu_items(params.auth_methods, None).len() - 1);
let text = render_done_text_h(&params, 40);
assert!(text.contains("Cancel"), "{text}");
assert!(!text.contains("Quit"), "{text}");
}
/// An old/limited shell that advertises only `kimi-code` keeps the
/// two-row shape (single login row + Quit) — and never a Moonshot row.
#[test]