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:
ZacharyZhang-NY committed 2026-07-22 16:14:42 -04:00
1 parent 1e57cd9225
commit fc7c2a1b9b
11 files changed
+384 -15

No files matched your search

+47 -5
View File
@@ -329,6 +329,29 @@ impl PendingMenuItem {
Self::Quit => "Quit",
}
}
/// Whether the shell advertised this row's method as already holding a
/// stored credential (`_meta.connected`, stamped at initialize and kept
/// fresh by the TUI after in-session logins). Drives the green
/// "connected" badge on the login picker.
pub fn connected(&self, auth_methods: &[acp::AuthMethod]) -> bool {
let id: &str = match self {
Self::Login {
method_id: Some(id),
..
} => id.0.as_ref(),
Self::ApiKey { target, .. } => target.platform_id().as_str(),
_ => return false,
};
auth_methods
.iter()
.find(|m| m.id().0.as_ref() == id)
.and_then(|m| {
let meta = m.meta()?;
meta.get(kigi_shell::agent::auth_method::CONNECTED_META_KEY)?
.as_bool()
})
.unwrap_or(false)
}
}
/// Build the welcome login-picker rows from the shell-advertised methods:
/// every INTERACTIVE method — the OAuth device login plus the Moonshot
@@ -704,6 +727,10 @@ pub struct AppView {
/// Login-picker menu viewport offset (minimal-scroll; fed back into the
/// next render so the list only moves when the selection exits it).
pub welcome_menu_scroll: usize,
/// The method an in-flight auth attempt targets. On `AuthComplete` this
/// method is stamped connected in `auth_methods` so a later `/login`
/// picker shows its badge without re-initializing the shell.
pub auth_in_flight_method: Option<acp::AuthMethodId>,
/// Hit-test rects for welcome menu items (populated during render).
pub welcome_menu_rects: Vec<ratatui::layout::Rect>,
/// Hit-test rect for the import-claude banner on the welcome screen.
@@ -1016,6 +1043,7 @@ impl AppView {
minimal_state: crate::minimal_api::MinimalState::default(),
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,
@@ -2590,17 +2618,22 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
}
return InputOutcome::Action(Action::QuitConfirmed);
}
// Esc closes a mid-session picker back to the session; on
// the startup picker there is nothing to go back to.
if key!(Esc).matches(key) && ctx.mid_session_login {
return InputOutcome::Action(Action::CancelLogin);
}
let items = pending_menu_items(ctx.auth_methods, None);
if let Some(outcome) = handle_menu_nav(key, ctx.menu_index, items.len()) {
return outcome;
}
// 'l' keeps its muscle-memory meaning: the first (OAuth) row.
if key!('l').matches(key) {
return dispatch_pending_menu_action(&items, 0);
return dispatch_pending_menu_action(&items, 0, ctx.mid_session_login);
}
if key!(Enter).matches(key) {
let index = ctx.menu_index.filter(|i| *i < items.len()).unwrap_or(0);
return dispatch_pending_menu_action(&items, index);
return dispatch_pending_menu_action(&items, index, ctx.mid_session_login);
}
}
AuthState::Authenticating { .. } if *ctx.show_raw_url => {
@@ -2713,7 +2746,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
{
if matches!(ctx.auth_state, AuthState::Pending { .. }) {
let items = pending_menu_items(ctx.auth_methods, None);
return dispatch_pending_menu_action(&items, i);
return dispatch_pending_menu_action(&items, i, ctx.mid_session_login);
}
if ctx.has_claude_import
&& i == 0
@@ -2831,7 +2864,13 @@ fn handle_menu_nav(
}
/// Dispatch an action for a welcome login-picker row (not yet authenticated).
/// Rows come from [`pending_menu_items`]: interactive methods + Quit.
fn dispatch_pending_menu_action(items: &[PendingMenuItem], index: usize) -> InputOutcome {
/// `mid_session` (a `/login` / re-auth picker over a live session) turns the
/// Quit row into Cancel — closing the picker must not exit the app.
fn dispatch_pending_menu_action(
items: &[PendingMenuItem],
index: usize,
mid_session: bool,
) -> InputOutcome {
match items.get(index) {
// The row's own method id rides along — every provider must start
// ITS OWN flow, not the first advertised (Kimi) one.
@@ -2845,6 +2884,7 @@ fn dispatch_pending_menu_action(items: &[PendingMenuItem], index: usize) -> Inpu
Some(PendingMenuItem::ApiKey { target, .. }) => {
InputOutcome::Action(Action::BeginPlatformKeyEntry(*target))
}
Some(PendingMenuItem::Quit) if mid_session => InputOutcome::Action(Action::CancelLogin),
Some(PendingMenuItem::Quit) => InputOutcome::Action(Action::Quit),
None => InputOutcome::Unchanged,
}
@@ -3188,6 +3228,7 @@ impl AppView {
flags: &flags_vec,
selected: self.welcome_menu_index,
menu_scroll: self.welcome_menu_scroll,
mid_session_login: self.auth_return_view.is_some(),
has_claude_import: self.has_claude_import,
mouse_pos: self.last_mouse_pos,
session_picker: self.session_picker_entries.as_deref(),
@@ -4321,6 +4362,7 @@ pub(crate) mod tests {
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,
@@ -7213,7 +7255,7 @@ pub(crate) mod tests {
(3, "github-copilot"),
(4, "openai-codex"),
] {
let outcome = dispatch_pending_menu_action(&items, index);
let outcome = dispatch_pending_menu_action(&items, index, false);
assert!(
matches!(
&outcome,