Login picker: offer all three platforms like the official CLI

The unauthenticated welcome screen previously offered only 'Login with
Kimi Code'. It now lists every interactive platform the shell
advertises, matching the official kimi-cli picker:

  Kimi Code (OAuth)
  Moonshot Open Platform (API key · moonshot.cn)
  Moonshot Open Platform (API key · moonshot.ai)
  Quit

- Shell: new ACP auth methods moonshot-cn / moonshot-ai (advertised
  after kimi-code; the BYOK first-position invariant holds).
  authenticate(moonshot-*) reloads keys from env>config, fails with an
  actionable message when none is configured, validates the key against
  GET {base}/models (401 → 'invalid API key' naming the console), then
  swaps the fresh config in, triggers the model sync, and reports
  auth_mode api_key so the pager treats it like other API-key logins.
  Never session-based; keys never logged.
- Config: save_platform_api_key persists [platforms.<id>].api_key via an
  atomic mode-preserving write under the config lock; refuses OAuth
  platforms, blank keys, and unparseable files.
- TUI: the Pending welcome renders the picker rows from the advertised
  methods (arrows/Enter/mouse; 'l' keeps selecting the OAuth row).
  Choosing a Moonshot row opens a masked paste box ('Paste your Moonshot
  API key (from platform.moonshot.cn)'); Esc returns to the picker,
  Enter persists the key and authenticates in one sequential effect;
  failures return to the picker with the error line, success lands on
  the normal welcome. Startup eager-auth is unchanged: a key already in
  the environment authenticates exactly as before, and single-method
  shells keep the historical auto-device-flow.

Gates: workspace check/clippy 0/0; shell 4870 + tui 6620 lib tests
green; headless probe advertises [kimi-code, moonshot-cn, moonshot-ai]
for a fresh user and xai.api_key-first with a key configured.
This commit is contained in:
2026-07-18 04:02:39 -04:00
parent f6253fbf56
commit 3952c28f16
14 changed files with 1253 additions and 31 deletions
@@ -7,7 +7,7 @@ use super::session::lifecycle::{clear_startup_actions, drain_startup_actions};
use crate::app::actions::{Action, Effect};
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState};
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, PlatformLogin};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
@@ -252,6 +252,74 @@ pub(super) fn dispatch_submit_auth_code(app: &mut AppView, code: String) -> Vec<
vec![Effect::SubmitAuthCode { request_seq, code }]
}
/// A Moonshot row was selected on the welcome login picker: switch the
/// welcome screen into the API-key paste box for that platform.
pub(super) fn dispatch_begin_platform_key_entry(
app: &mut AppView,
target: PlatformLogin,
) -> Vec<Effect> {
let request_seq = app.next_auth_request_seq;
app.next_auth_request_seq += 1;
app.auth_code_input.clear();
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: AuthMode::ApiKeyEntry(target),
};
vec![]
}
/// Esc in the API-key paste box: back to the login picker (no error line).
/// Bumps the request seq so any stale in-flight auth result is dropped by
/// the `AuthComplete`/`AuthFailed` guards.
pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effect> {
if !matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::ApiKeyEntry(_),
..
}
) {
return vec![];
}
app.next_auth_request_seq += 1;
app.auth_code_input.clear();
app.auth_state = AuthState::Pending { error: None };
vec![]
}
/// Enter with a non-empty key in the API-key paste box: persist the key to
/// `[platforms.<id>]` in config.toml, then authenticate with the platform's
/// method id (one sequential background task — see the effect handler).
/// The screen shows the connecting state while the key is validated; a
/// failure lands back on the picker with the error line (`AuthFailed`).
pub(super) fn dispatch_submit_platform_api_key(app: &mut AppView, key: String) -> Vec<Effect> {
let (request_seq, target) = match &app.auth_state {
AuthState::Authenticating {
request_seq,
mode: AuthMode::ApiKeyEntry(target),
..
} => (*request_seq, *target),
_ => return vec![],
};
let key = key.trim().to_string();
if key.is_empty() {
return vec![];
}
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
vec![Effect::PersistPlatformApiKeyAndAuthenticate {
request_seq,
target,
key,
}]
}
// TaskResult handlers.
pub(super) fn handle_auth_complete(
@@ -1,6 +1,7 @@
//! Top-level action router: maps actions and action results to handlers.
use super::auth::{
dispatch_cancel_login, dispatch_login, dispatch_logout, dispatch_submit_auth_code,
dispatch_begin_platform_key_entry, dispatch_cancel_login, dispatch_cancel_platform_key_entry,
dispatch_login, dispatch_logout, dispatch_submit_auth_code, dispatch_submit_platform_api_key,
dispatch_switch_account,
};
use super::ctx::{
@@ -880,6 +881,9 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::Login => dispatch_login(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),
Action::CancelPlatformKeyEntry => dispatch_cancel_platform_key_entry(app),
Action::SubmitPlatformApiKey(key) => dispatch_submit_platform_api_key(app, key),
Action::CopyAuthUrl => {
if let AuthState::Authenticating {
auth_url: Some(url),
@@ -100,6 +100,120 @@ fn auth_complete_with_deferred_load_also_fetches_status() {
assert!(app.deferred_startup.session.is_none());
}
/// The Moonshot API-key entry flow: picking a row opens the paste box,
/// submitting a key dispatches ONE effect that persists the key and then
/// authenticates with the platform's method id, and the visual flips to the
/// connecting state under the same request seq.
#[test]
fn submit_platform_api_key_dispatches_persist_then_authenticate() {
use crate::app::app_view::PlatformLogin;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
let effects = dispatch(
Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotCn),
&mut app,
);
assert!(effects.is_empty(), "entering key entry is UI-only");
let seq = match &app.auth_state {
AuthState::Authenticating {
request_seq,
mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotCn),
..
} => *request_seq,
other => panic!("expected ApiKeyEntry(MoonshotCn), got {other:?}"),
};
let effects = dispatch(Action::SubmitPlatformApiKey("sk-test-key".into()), &mut app);
match effects.as_slice() {
[
Effect::PersistPlatformApiKeyAndAuthenticate {
request_seq,
target,
key,
},
] => {
assert_eq!(*request_seq, seq);
assert_eq!(*target, PlatformLogin::MoonshotCn);
assert_eq!(key, "sk-test-key");
assert_eq!(
target.method_id().0.as_ref(),
"moonshot-cn",
"authenticate must use the shell's moonshot-cn method id"
);
}
other => panic!("expected exactly the persist+authenticate effect, got {other:?}"),
}
// Same seq, connecting visual: this attempt's AuthComplete/AuthFailed
// still matches.
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
request_seq,
mode: AuthMode::Pending,
..
} if request_seq == seq
));
// Failed validation lands back on the picker with the error line.
dispatch(
Action::TaskComplete(TaskResult::AuthFailed {
request_seq: seq,
error: "Invalid API key for moonshot-cn".into(),
}),
&mut app,
);
assert!(matches!(
&app.auth_state,
AuthState::Pending { error: Some(e) } if e == "Invalid API key for moonshot-cn"
));
}
/// Esc in the paste box returns to the picker (no error), clears the typed
/// key, and invalidates the seq so stale auth results are dropped.
#[test]
fn cancel_platform_key_entry_returns_to_picker() {
use crate::app::app_view::PlatformLogin;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotAi),
&mut app,
);
app.auth_code_input = "sk-half-typed".into();
let seq_before = app.next_auth_request_seq;
let effects = dispatch(Action::CancelPlatformKeyEntry, &mut app);
assert!(effects.is_empty());
assert!(matches!(app.auth_state, AuthState::Pending { error: None }));
assert!(app.auth_code_input.is_empty(), "typed key must be cleared");
assert!(app.next_auth_request_seq > seq_before);
}
/// A submitted empty/whitespace key is a no-op (stays in the paste box).
#[test]
fn submit_platform_api_key_ignores_blank_key() {
use crate::app::app_view::PlatformLogin;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotCn),
&mut app,
);
let effects = dispatch(Action::SubmitPlatformApiKey(" ".into()), &mut app);
assert!(effects.is_empty());
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotCn),
..
}
));
}
/// `/login` from the welcome screen (startup / logged-out) must NOT
/// stash a return view — the normal login-then-load flow is preserved.
#[test]