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:
@@ -437,6 +437,68 @@ pub fn platform_auth_method(platform: kigi_models::PlatformId) -> acp::AuthMetho
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `_meta` key advertised on auth methods that already hold a stored
|
||||||
|
/// credential: the client's login picker renders these rows with a green
|
||||||
|
/// "connected" badge. Display state only — NEVER an authorization input.
|
||||||
|
pub const CONNECTED_META_KEY: &str = "connected";
|
||||||
|
|
||||||
|
/// Which advertised method ids already hold a stored credential.
|
||||||
|
///
|
||||||
|
/// Pure given its inputs so it unit-tests without disk or env:
|
||||||
|
/// - `has_cached_token` / `has_external_api_key` — the same flags
|
||||||
|
/// `build_auth_methods` consumed,
|
||||||
|
/// - `has_scope` — whether auth.json holds an entry at a scope key
|
||||||
|
/// (subscription-OAuth sessions live at `oauth/<provider>`),
|
||||||
|
/// - `has_platform_key` — whether an API-key platform resolves a key
|
||||||
|
/// (env > auth.json > config; see `PlatformApiKeys`).
|
||||||
|
pub fn connected_method_ids(
|
||||||
|
has_cached_token: bool,
|
||||||
|
has_external_api_key: bool,
|
||||||
|
has_scope: impl Fn(&str) -> bool,
|
||||||
|
has_platform_key: impl Fn(kigi_models::PlatformId) -> bool,
|
||||||
|
) -> std::collections::HashSet<&'static str> {
|
||||||
|
let mut connected = std::collections::HashSet::new();
|
||||||
|
if has_cached_token {
|
||||||
|
connected.insert(KIMI_CODE_METHOD_ID);
|
||||||
|
connected.insert(CACHED_TOKEN_AUTH_METHOD_ID);
|
||||||
|
}
|
||||||
|
if has_external_api_key {
|
||||||
|
connected.insert(XAI_API_KEY_METHOD_ID);
|
||||||
|
}
|
||||||
|
for platform in kigi_models::PlatformId::ALL {
|
||||||
|
match platform.oauth() {
|
||||||
|
Some(oauth) if has_scope(oauth.scope_key) => {
|
||||||
|
connected.insert(platform.as_str());
|
||||||
|
}
|
||||||
|
None if !platform.uses_oauth() && has_platform_key(platform) => {
|
||||||
|
connected.insert(platform.as_str());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connected
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamp [`CONNECTED_META_KEY`] onto every advertised method in `connected`.
|
||||||
|
pub fn stamp_connected_meta(
|
||||||
|
methods: &mut [acp::AuthMethod],
|
||||||
|
connected: &std::collections::HashSet<&'static str>,
|
||||||
|
) {
|
||||||
|
for method in methods.iter_mut() {
|
||||||
|
if !connected.contains(method.id().0.as_ref()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let acp::AuthMethod::Agent(agent) = method {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Actionable error for a platform `authenticate` with no key configured.
|
/// Actionable error for a platform `authenticate` with no key configured.
|
||||||
pub fn missing_platform_key_error(platform: kigi_models::PlatformId) -> String {
|
pub fn missing_platform_key_error(platform: kigi_models::PlatformId) -> String {
|
||||||
match platform.api_key_env_names().first() {
|
match platform.api_key_env_names().first() {
|
||||||
@@ -1064,6 +1126,53 @@ mod tests {
|
|||||||
assert_eq!(read_xai_api_key_env().unwrap(), "house-key");
|
assert_eq!(read_xai_api_key_env().unwrap(), "house-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Connected-badge probing: the primary session marks kimi-code (and
|
||||||
|
/// cached_token), a stored `oauth/<provider>` scope marks that provider,
|
||||||
|
/// a resolvable platform key marks its API-key row — and nothing else.
|
||||||
|
#[test]
|
||||||
|
fn connected_method_ids_maps_credentials_to_method_ids() {
|
||||||
|
let connected = connected_method_ids(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
|scope| scope == "oauth/claude-pro-max",
|
||||||
|
|p| p == kigi_models::PlatformId::DeepSeek,
|
||||||
|
);
|
||||||
|
assert!(connected.contains(KIMI_CODE_METHOD_ID));
|
||||||
|
assert!(connected.contains(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||||
|
assert!(connected.contains("claude-pro-max"));
|
||||||
|
assert!(connected.contains("deepseek"));
|
||||||
|
assert!(!connected.contains("xai-grok"), "no stored grok session");
|
||||||
|
assert!(!connected.contains("openai"), "no openai key");
|
||||||
|
assert!(!connected.contains(XAI_API_KEY_METHOD_ID), "no house key");
|
||||||
|
|
||||||
|
// Nothing stored → nothing connected.
|
||||||
|
let none = connected_method_ids(false, false, |_| false, |_| false);
|
||||||
|
assert!(none.is_empty(), "{none:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamping writes `_meta.connected: true` on exactly the connected
|
||||||
|
/// methods and leaves every other method's meta untouched.
|
||||||
|
#[test]
|
||||||
|
fn stamp_connected_meta_marks_only_connected_methods() {
|
||||||
|
let mut methods = build_auth_methods(default_inputs()).methods;
|
||||||
|
let connected = std::collections::HashSet::from(["claude-pro-max"]);
|
||||||
|
stamp_connected_meta(&mut methods, &connected);
|
||||||
|
for method in &methods {
|
||||||
|
let marked = method
|
||||||
|
.meta()
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.get(CONNECTED_META_KEY))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
assert_eq!(
|
||||||
|
marked,
|
||||||
|
method.id().0.as_ref() == "claude-pro-max",
|
||||||
|
"only claude-pro-max may carry the badge, got it on {}",
|
||||||
|
method.id().0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Moonshot authenticate with no configured key: actionable error naming
|
/// Moonshot authenticate with no configured key: actionable error naming
|
||||||
/// the platform, the login screen, and the platform-scoped env var. No
|
/// the platform, the login screen, and the platform-scoped env var. No
|
||||||
/// HTTP is attempted (`key: None` short-circuits).
|
/// HTTP is attempted (`key: None` short-circuits).
|
||||||
|
|||||||
@@ -221,7 +221,24 @@ impl acp::Agent for MvpAgent {
|
|||||||
has_cached_token,
|
has_cached_token,
|
||||||
login_label: None,
|
login_label: None,
|
||||||
});
|
});
|
||||||
let auth_methods = built.methods;
|
let mut auth_methods = built.methods;
|
||||||
|
// Connected badges for the client's login picker: probe stored
|
||||||
|
// credentials once (auth.json scopes + resolved platform keys) and
|
||||||
|
// stamp `_meta.connected` on every method that already has one.
|
||||||
|
{
|
||||||
|
let store = crate::auth::read_auth_json(
|
||||||
|
&crate::util::kigi_home::kigi_home().join("auth.json"),
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let keys = crate::agent::models::PlatformApiKeys::resolve_from_effective_config();
|
||||||
|
let connected = auth_method::connected_method_ids(
|
||||||
|
has_cached_token,
|
||||||
|
has_external_api_key,
|
||||||
|
|scope| store.contains_key(scope),
|
||||||
|
|p| keys.key_for(p).is_some(),
|
||||||
|
);
|
||||||
|
auth_method::stamp_connected_meta(&mut auth_methods, &connected);
|
||||||
|
}
|
||||||
kigi_log::unified_log::info(
|
kigi_log::unified_log::info(
|
||||||
"auth: initialize() built auth_methods for ACP response",
|
"auth: initialize() built auth_methods for ACP response",
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -567,6 +567,10 @@ pub enum Action {
|
|||||||
/// first interactive method, which sent every provider row to the Kimi
|
/// first interactive method, which sent every provider row to the Kimi
|
||||||
/// device flow.
|
/// device flow.
|
||||||
LoginWith(acp::AuthMethodId),
|
LoginWith(acp::AuthMethodId),
|
||||||
|
/// `/login`: show the provider picker (with connected badges) instead of
|
||||||
|
/// auto-starting a flow. The user picks a row; `LoginWith` / key entry
|
||||||
|
/// take it from there.
|
||||||
|
OpenLoginPicker,
|
||||||
/// Cancel an in-progress login that was started from inside a session
|
/// Cancel an in-progress login that was started from inside a session
|
||||||
/// (`/login` or a 401 re-auth prompt) and return to the previous view.
|
/// (`/login` or a 401 re-auth prompt) and return to the previous view.
|
||||||
/// Distinct from `Quit`: abandoning a mid-session re-auth must not exit
|
/// Distinct from `Quit`: abandoning a mid-session re-auth must not exit
|
||||||
|
|||||||
@@ -329,6 +329,29 @@ impl PendingMenuItem {
|
|||||||
Self::Quit => "Quit",
|
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:
|
/// Build the welcome login-picker rows from the shell-advertised methods:
|
||||||
/// every INTERACTIVE method — the OAuth device login plus the Moonshot
|
/// 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
|
/// Login-picker menu viewport offset (minimal-scroll; fed back into the
|
||||||
/// next render so the list only moves when the selection exits it).
|
/// next render so the list only moves when the selection exits it).
|
||||||
pub welcome_menu_scroll: usize,
|
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).
|
/// Hit-test rects for welcome menu items (populated during render).
|
||||||
pub welcome_menu_rects: Vec<ratatui::layout::Rect>,
|
pub welcome_menu_rects: Vec<ratatui::layout::Rect>,
|
||||||
/// Hit-test rect for the import-claude banner on the welcome screen.
|
/// 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(),
|
minimal_state: crate::minimal_api::MinimalState::default(),
|
||||||
welcome_menu_index: None,
|
welcome_menu_index: None,
|
||||||
welcome_menu_scroll: 0,
|
welcome_menu_scroll: 0,
|
||||||
|
auth_in_flight_method: None,
|
||||||
welcome_menu_rects: Vec::new(),
|
welcome_menu_rects: Vec::new(),
|
||||||
welcome_import_banner_rect: None,
|
welcome_import_banner_rect: None,
|
||||||
last_mouse_pos: None,
|
last_mouse_pos: None,
|
||||||
@@ -2590,17 +2618,22 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
|||||||
}
|
}
|
||||||
return InputOutcome::Action(Action::QuitConfirmed);
|
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);
|
let items = pending_menu_items(ctx.auth_methods, None);
|
||||||
if let Some(outcome) = handle_menu_nav(key, ctx.menu_index, items.len()) {
|
if let Some(outcome) = handle_menu_nav(key, ctx.menu_index, items.len()) {
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
// 'l' keeps its muscle-memory meaning: the first (OAuth) row.
|
// 'l' keeps its muscle-memory meaning: the first (OAuth) row.
|
||||||
if key!('l').matches(key) {
|
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) {
|
if key!(Enter).matches(key) {
|
||||||
let index = ctx.menu_index.filter(|i| *i < items.len()).unwrap_or(0);
|
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 => {
|
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 { .. }) {
|
if matches!(ctx.auth_state, AuthState::Pending { .. }) {
|
||||||
let items = pending_menu_items(ctx.auth_methods, None);
|
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
|
if ctx.has_claude_import
|
||||||
&& i == 0
|
&& i == 0
|
||||||
@@ -2831,7 +2864,13 @@ fn handle_menu_nav(
|
|||||||
}
|
}
|
||||||
/// Dispatch an action for a welcome login-picker row (not yet authenticated).
|
/// Dispatch an action for a welcome login-picker row (not yet authenticated).
|
||||||
/// Rows come from [`pending_menu_items`]: interactive methods + Quit.
|
/// 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) {
|
match items.get(index) {
|
||||||
// The row's own method id rides along — every provider must start
|
// The row's own method id rides along — every provider must start
|
||||||
// ITS OWN flow, not the first advertised (Kimi) one.
|
// 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, .. }) => {
|
Some(PendingMenuItem::ApiKey { target, .. }) => {
|
||||||
InputOutcome::Action(Action::BeginPlatformKeyEntry(*target))
|
InputOutcome::Action(Action::BeginPlatformKeyEntry(*target))
|
||||||
}
|
}
|
||||||
|
Some(PendingMenuItem::Quit) if mid_session => InputOutcome::Action(Action::CancelLogin),
|
||||||
Some(PendingMenuItem::Quit) => InputOutcome::Action(Action::Quit),
|
Some(PendingMenuItem::Quit) => InputOutcome::Action(Action::Quit),
|
||||||
None => InputOutcome::Unchanged,
|
None => InputOutcome::Unchanged,
|
||||||
}
|
}
|
||||||
@@ -3188,6 +3228,7 @@ impl AppView {
|
|||||||
flags: &flags_vec,
|
flags: &flags_vec,
|
||||||
selected: self.welcome_menu_index,
|
selected: self.welcome_menu_index,
|
||||||
menu_scroll: self.welcome_menu_scroll,
|
menu_scroll: self.welcome_menu_scroll,
|
||||||
|
mid_session_login: self.auth_return_view.is_some(),
|
||||||
has_claude_import: self.has_claude_import,
|
has_claude_import: self.has_claude_import,
|
||||||
mouse_pos: self.last_mouse_pos,
|
mouse_pos: self.last_mouse_pos,
|
||||||
session_picker: self.session_picker_entries.as_deref(),
|
session_picker: self.session_picker_entries.as_deref(),
|
||||||
@@ -4321,6 +4362,7 @@ pub(crate) mod tests {
|
|||||||
welcome_tip_typing_dismissed: false,
|
welcome_tip_typing_dismissed: false,
|
||||||
welcome_menu_index: None,
|
welcome_menu_index: None,
|
||||||
welcome_menu_scroll: 0,
|
welcome_menu_scroll: 0,
|
||||||
|
auth_in_flight_method: None,
|
||||||
welcome_menu_rects: Vec::new(),
|
welcome_menu_rects: Vec::new(),
|
||||||
welcome_import_banner_rect: None,
|
welcome_import_banner_rect: None,
|
||||||
last_mouse_pos: None,
|
last_mouse_pos: None,
|
||||||
@@ -7213,7 +7255,7 @@ pub(crate) mod tests {
|
|||||||
(3, "github-copilot"),
|
(3, "github-copilot"),
|
||||||
(4, "openai-codex"),
|
(4, "openai-codex"),
|
||||||
] {
|
] {
|
||||||
let outcome = dispatch_pending_menu_action(&items, index);
|
let outcome = dispatch_pending_menu_action(&items, index, false);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
&outcome,
|
&outcome,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ pub(super) fn dispatch_switch_account(app: &mut AppView) -> Vec<Effect> {
|
|||||||
let request_seq = app.next_auth_request_seq;
|
let request_seq = app.next_auth_request_seq;
|
||||||
app.next_auth_request_seq += 1;
|
app.next_auth_request_seq += 1;
|
||||||
app.auth_code_input.clear();
|
app.auth_code_input.clear();
|
||||||
|
app.auth_in_flight_method = Some(method_id.clone());
|
||||||
app.auth_state = AuthState::Authenticating {
|
app.auth_state = AuthState::Authenticating {
|
||||||
request_seq,
|
request_seq,
|
||||||
handle: None,
|
handle: None,
|
||||||
@@ -178,6 +179,25 @@ pub(super) fn dispatch_login(app: &mut AppView) -> Vec<Effect> {
|
|||||||
dispatch_login_with(app, None)
|
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
|
/// Start an interactive login flow with an explicitly chosen method (a
|
||||||
/// provider row on the login picker). `None` keeps the historical behavior:
|
/// provider row on the login picker). `None` keeps the historical behavior:
|
||||||
/// re-use the current method or resolve the first interactive one.
|
/// 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;
|
let request_seq = app.next_auth_request_seq;
|
||||||
app.next_auth_request_seq += 1;
|
app.next_auth_request_seq += 1;
|
||||||
app.auth_code_input.clear();
|
app.auth_code_input.clear();
|
||||||
|
app.auth_in_flight_method = Some(method_id.clone());
|
||||||
app.auth_state = AuthState::Authenticating {
|
app.auth_state = AuthState::Authenticating {
|
||||||
request_seq,
|
request_seq,
|
||||||
handle: None,
|
handle: None,
|
||||||
@@ -265,6 +286,7 @@ pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec<Effect> {
|
|||||||
app.auth_state = AuthState::Done;
|
app.auth_state = AuthState::Done;
|
||||||
app.auth_show_raw_url = false;
|
app.auth_show_raw_url = false;
|
||||||
app.auth_code_input.clear();
|
app.auth_code_input.clear();
|
||||||
|
app.auth_in_flight_method = None;
|
||||||
restore_auth_return_view(app, return_view);
|
restore_auth_return_view(app, return_view);
|
||||||
// The user bailed out of re-auth — drop stashed prompts and strip the
|
// 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
|
// 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() {
|
if key.is_empty() {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
app.auth_in_flight_method = Some(target.method_id());
|
||||||
app.auth_state = AuthState::Authenticating {
|
app.auth_state = AuthState::Authenticating {
|
||||||
request_seq,
|
request_seq,
|
||||||
handle: None,
|
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.
|
// TaskResult handlers.
|
||||||
|
|
||||||
pub(super) fn handle_auth_complete(
|
pub(super) fn handle_auth_complete(
|
||||||
@@ -378,6 +423,13 @@ pub(super) fn handle_auth_complete(
|
|||||||
app.apply_auth_meta(&auth_meta);
|
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_state = AuthState::Done;
|
||||||
app.auth_show_raw_url = false;
|
app.auth_show_raw_url = false;
|
||||||
app.welcome_prompt_focused = true;
|
app.welcome_prompt_focused = true;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Top-level action router: maps actions and action results to handlers.
|
//! Top-level action router: maps actions and action results to handlers.
|
||||||
use super::auth::{
|
use super::auth::{
|
||||||
dispatch_begin_platform_key_entry, dispatch_cancel_login, dispatch_cancel_platform_key_entry,
|
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_login, dispatch_login_with, dispatch_logout, dispatch_open_login_picker,
|
||||||
dispatch_submit_platform_api_key, dispatch_switch_account,
|
dispatch_submit_auth_code, dispatch_submit_platform_api_key, dispatch_switch_account,
|
||||||
};
|
};
|
||||||
use super::ctx::{
|
use super::ctx::{
|
||||||
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
|
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::Login => dispatch_login(app),
|
||||||
Action::LoginWith(method_id) => dispatch_login_with(app, Some(method_id)),
|
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::CancelLogin => dispatch_cancel_login(app),
|
||||||
Action::SubmitAuthCode(code) => dispatch_submit_auth_code(app, code),
|
Action::SubmitAuthCode(code) => dispatch_submit_auth_code(app, code),
|
||||||
Action::BeginPlatformKeyEntry(target) => dispatch_begin_platform_key_entry(app, target),
|
Action::BeginPlatformKeyEntry(target) => dispatch_begin_platform_key_entry(app, target),
|
||||||
|
|||||||
@@ -506,3 +506,72 @@ fn login_with_unknown_method_fails_closed() {
|
|||||||
app.auth_state
|
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_tip_typing_dismissed: false,
|
||||||
welcome_menu_index: None,
|
welcome_menu_index: None,
|
||||||
welcome_menu_scroll: 0,
|
welcome_menu_scroll: 0,
|
||||||
|
auth_in_flight_method: None,
|
||||||
welcome_menu_rects: Vec::new(),
|
welcome_menu_rects: Vec::new(),
|
||||||
welcome_import_banner_rect: None,
|
welcome_import_banner_rect: None,
|
||||||
last_mouse_pos: None,
|
last_mouse_pos: None,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! `/login` -- log in or re-authenticate with your account.
|
//! `/login` -- pick a provider to log in or re-authenticate with.
|
||||||
|
|
||||||
use crate::app::actions::Action;
|
use crate::app::actions::Action;
|
||||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||||
@@ -11,14 +11,17 @@ impl SlashCommand for LoginCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Log in or re-authenticate with your account"
|
"Pick a provider to log in with (connected ones are marked)"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usage(&self) -> &str {
|
fn usage(&self) -> &str {
|
||||||
"/login"
|
"/login"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the provider picker rather than auto-starting a flow: the user
|
||||||
|
/// chooses a row there, and already-connected providers show a green
|
||||||
|
/// "connected" badge.
|
||||||
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
||||||
CommandResult::Action(Action::Login)
|
CommandResult::Action(Action::OpenLoginPicker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ use crate::theme::Theme;
|
|||||||
|
|
||||||
use super::logo::logo_visual_width;
|
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.
|
/// 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
|
/// 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);
|
buf.set_span(menu_centered.x, y, &Span::styled(*label, lstyle), label_len);
|
||||||
|
|
||||||
// Key shortcut flush with the right edge of the menu column.
|
// Key shortcut flush with the right edge of the menu column. The
|
||||||
let kstyle = if is_selected {
|
// "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
|
key_selected_style
|
||||||
} else {
|
} else {
|
||||||
key_style
|
key_style
|
||||||
|
|||||||
@@ -468,6 +468,10 @@ pub struct WelcomeRenderParams<'a> {
|
|||||||
/// scroll viewport; the frame's actual offset comes back in
|
/// scroll viewport; the frame's actual offset comes back in
|
||||||
/// [`WelcomeRenderResult::menu_scroll`]).
|
/// [`WelcomeRenderResult::menu_scroll`]).
|
||||||
pub menu_scroll: usize,
|
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 has_claude_import: bool,
|
||||||
pub mouse_pos: Option<(u16, u16)>,
|
pub mouse_pos: Option<(u16, u16)>,
|
||||||
pub session_picker: Option<&'a [SessionPickerEntry]>,
|
pub session_picker: Option<&'a [SessionPickerEntry]>,
|
||||||
@@ -534,13 +538,28 @@ pub fn render_welcome(
|
|||||||
|
|
||||||
let mut result = match params.auth_state {
|
let mut result = match params.auth_state {
|
||||||
AuthState::Pending { error } => {
|
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> =
|
let items: Vec<PendingMenuItem> =
|
||||||
pending_menu_items(params.auth_methods, params.login_label);
|
pending_menu_items(params.auth_methods, params.login_label);
|
||||||
let menu: Vec<(&str, &str)> = items
|
let menu: Vec<(&str, &str)> = items
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.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();
|
.collect();
|
||||||
let msg = error.as_deref().map(|e| (e, theme.accent_error));
|
let msg = error.as_deref().map(|e| (e, theme.accent_error));
|
||||||
let info = PromptInfo {
|
let info = PromptInfo {
|
||||||
@@ -2088,6 +2107,7 @@ mod tests {
|
|||||||
flags: &[],
|
flags: &[],
|
||||||
selected: None,
|
selected: None,
|
||||||
menu_scroll: 0,
|
menu_scroll: 0,
|
||||||
|
mid_session_login: false,
|
||||||
has_claude_import: false,
|
has_claude_import: false,
|
||||||
mouse_pos: None,
|
mouse_pos: None,
|
||||||
session_picker,
|
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(¶ms, 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(¶ms, 40);
|
||||||
|
assert!(text.contains("Cancel"), "{text}");
|
||||||
|
assert!(!text.contains("Quit"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
/// An old/limited shell that advertises only `kimi-code` keeps the
|
/// An old/limited shell that advertises only `kimi-code` keeps the
|
||||||
/// two-row shape (single login row + Quit) — and never a Moonshot row.
|
/// two-row shape (single login row + Quit) — and never a Moonshot row.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user