diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index a581b0f..72231a7 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -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/`), +/// - `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. pub fn missing_platform_key_error(platform: kigi_models::PlatformId) -> String { match platform.api_key_env_names().first() { @@ -1064,6 +1126,53 @@ mod tests { 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/` 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 /// the platform, the login screen, and the platform-scoped env var. No /// HTTP is attempted (`key: None` short-circuits). diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs index 9d8051b..64759c2 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs @@ -221,7 +221,24 @@ impl acp::Agent for MvpAgent { has_cached_token, 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( "auth: initialize() built auth_methods for ACP response", None, diff --git a/crates/codegen/kigi-tui/src/app/actions.rs b/crates/codegen/kigi-tui/src/app/actions.rs index abccd2e..2f99670 100644 --- a/crates/codegen/kigi-tui/src/app/actions.rs +++ b/crates/codegen/kigi-tui/src/app/actions.rs @@ -567,6 +567,10 @@ pub enum Action { /// first interactive method, which sent every provider row to the Kimi /// device flow. 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 /// (`/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 diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index bdd87f2..c5a4a38 100644 --- a/crates/codegen/kigi-tui/src/app/app_view.rs +++ b/crates/codegen/kigi-tui/src/app/app_view.rs @@ -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, /// Hit-test rects for welcome menu items (populated during render). pub welcome_menu_rects: Vec, /// 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, diff --git a/crates/codegen/kigi-tui/src/app/dispatch/auth.rs b/crates/codegen/kigi-tui/src/app/dispatch/auth.rs index d319d14..b9eda10 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/auth.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/auth.rs @@ -64,6 +64,7 @@ pub(super) fn dispatch_switch_account(app: &mut AppView) -> Vec { 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 { 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 { + 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 { 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; diff --git a/crates/codegen/kigi-tui/src/app/dispatch/router.rs b/crates/codegen/kigi-tui/src/app/dispatch/router.rs index 479df9b..39f0497 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/router.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/router.rs @@ -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 { } 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), diff --git a/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs b/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs index 5ab7c2f..4b75873 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs @@ -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" + ); +} diff --git a/crates/codegen/kigi-tui/src/app/dispatch/tests/mod.rs b/crates/codegen/kigi-tui/src/app/dispatch/tests/mod.rs index ae945e4..8afe5c2 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/tests/mod.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/tests/mod.rs @@ -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, diff --git a/crates/codegen/kigi-tui/src/slash/commands/login.rs b/crates/codegen/kigi-tui/src/slash/commands/login.rs index 9c6234a..dc91d18 100644 --- a/crates/codegen/kigi-tui/src/slash/commands/login.rs +++ b/crates/codegen/kigi-tui/src/slash/commands/login.rs @@ -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::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; @@ -11,14 +11,17 @@ impl SlashCommand for LoginCommand { } 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 { "/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 { - CommandResult::Action(Action::Login) + CommandResult::Action(Action::OpenLoginPicker) } } diff --git a/crates/codegen/kigi-tui/src/views/welcome/menu.rs b/crates/codegen/kigi-tui/src/views/welcome/menu.rs index c1a1c82..6e84cef 100644 --- a/crates/codegen/kigi-tui/src/views/welcome/menu.rs +++ b/crates/codegen/kigi-tui/src/views/welcome/menu.rs @@ -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 diff --git a/crates/codegen/kigi-tui/src/views/welcome/mod.rs b/crates/codegen/kigi-tui/src/views/welcome/mod.rs index 8956f6a..30d0032 100644 --- a/crates/codegen/kigi-tui/src/views/welcome/mod.rs +++ b/crates/codegen/kigi-tui/src/views/welcome/mod.rs @@ -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 = 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(¶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 /// two-row shape (single login row + Quit) — and never a Moonshot row. #[test]