Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/tui/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@
"OnboardLanguageFooter": "Press 1-9 to choose, or Enter to keep the current setting",
"OnboardProviderTitle": "Choose your model provider",
"OnboardProviderBlurb": "Pick where your model runs. Hosted providers need a key; local runtimes can continue without one.",
"OnboardProviderFooter": "Press 0-9 to choose, ↑/↓ to move, Enter to continue, Esc to go back.",
"OnboardProviderFooter": "Enter opens the provider list · Esc goes back · Ctrl+C quits.",
"OnboardApiKeyTitle": "Connect your API key",
"OnboardApiKeyStep1": "Step 1. Open your provider credential page and create a key for",
"OnboardApiKeyLocalHint": "Local runtimes usually need no pasted key — start the server, then press Enter.",
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/locales/es-419.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@
"OnboardLanguageFooter": "Presiona 1-9 para elegir, o Enter para mantener la configuración actual",
"OnboardProviderTitle": "Elige tu proveedor de modelos",
"OnboardProviderBlurb": "Elige dónde se ejecutará tu modelo. Los proveedores alojados necesitan una clave; los runtimes locales pueden continuar sin ella.",
"OnboardProviderFooter": "Presiona 0-9 para elegir, ↑/↓ para mover, Enter para continuar, Esc para volver.",
"OnboardProviderFooter": "Enter abre la lista de proveedores · Esc vuelve · Ctrl+C sale.",
"OnboardApiKeyTitle": "Conecta tu clave API",
"OnboardApiKeyStep1": "Paso 1. Abre la página de credenciales de tu proveedor y crea una clave para",
"OnboardApiKeyLocalHint": "Los runtimes locales normalmente no requieren pegar una clave — inicia el servidor y presiona Enter.",
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@
"OnboardLanguageFooter": "1〜9 で選択、または Enter で現在の設定を維持",
"OnboardProviderTitle": "モデルプロバイダーを選択",
"OnboardProviderBlurb": "モデルの実行先を選びます。ホステッドプロバイダーにはキーが必要ですが、ローカルランタイムはキーなしで続行できます。",
"OnboardProviderFooter": "0-9 で選択、↑/↓ で移動、Enter で続行、Esc で戻る。",
"OnboardProviderFooter": "Enter でプロバイダー一覧を開く · Esc で戻る · Ctrl+C で終了。",
"OnboardApiKeyTitle": "API キーを接続",
"OnboardApiKeyStep1": "ステップ 1. プロバイダーの認証情報ページを開き、次のキーを作成:",
"OnboardApiKeyLocalHint": "ローカルランタイムは通常キーの貼り付け不要 — サーバーを起動して Enter。",
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@
"OnboardLanguageFooter": "1-9를 눌러 선택하거나 Enter로 현재 설정을 유지하세요",
"OnboardProviderTitle": "모델 프로바이더를 선택하세요",
"OnboardProviderBlurb": "모델이 실행될 위치를 선택하세요. 호스팅 프로바이더는 키가 필요하지만 로컬 런타임은 키 없이 계속할 수 있습니다.",
"OnboardProviderFooter": "0-9를 눌러 선택하거나 ↑/↓로 이동, Enter로 계속, Esc로 뒤로 가세요.",
"OnboardProviderFooter": "Enter로 프로바이더 목록을 열고 · Esc로 뒤로 가며 · Ctrl+C로 종료합니다.",
"OnboardApiKeyTitle": "API 키를 연결하세요",
"OnboardApiKeyStep1": "1단계. 프로바이더의 자격 증명 페이지를 열고 다음의 키를 생성하세요:",
"OnboardApiKeyLocalHint": "로컬 런타임은 보통 키를 붙여넣을 필요가 없습니다. 서버를 시작한 뒤 Enter를 누르세요.",
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@
"OnboardLanguageFooter": "Pressione 1-9 para escolher, ou Enter para manter a configuração atual",
"OnboardProviderTitle": "Escolha seu provedor de modelos",
"OnboardProviderBlurb": "Escolha onde o modelo será executado. Provedores hospedados precisam de chave; runtimes locais podem continuar sem uma.",
"OnboardProviderFooter": "Pressione 0-9 para escolher, ↑/↓ para mover, Enter para continuar, Esc para voltar.",
"OnboardProviderFooter": "Enter abre a lista de provedores · Esc volta · Ctrl+C sai.",
"OnboardApiKeyTitle": "Conecte sua chave API",
"OnboardApiKeyStep1": "Passo 1. Abra a página de credenciais do provedor e crie uma chave para",
"OnboardApiKeyLocalHint": "Runtimes locais geralmente não precisam de chave colada — inicie o servidor e pressione Enter.",
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@
"OnboardLanguageFooter": "Nhấn phím từ 1-9 để chọn, hoặc Enter để giữ cài đặt hiện tại",
"OnboardProviderTitle": "Chọn nhà cung cấp mô hình",
"OnboardProviderBlurb": "Chọn nơi mô hình chạy. Nhà cung cấp được lưu trữ cần khóa; runtime cục bộ có thể tiếp tục mà không cần khóa.",
"OnboardProviderFooter": "Nhấn 0-9 để chọn, ↑/↓ để di chuyển, Enter để tiếp tục, Esc để quay lại.",
"OnboardProviderFooter": "Enter mở danh sách nhà cung cấp · Esc quay lại · Ctrl+C thoát.",
"OnboardApiKeyTitle": "Kết nối khóa API của bạn",
"OnboardApiKeyStep1": "Bước 1. Mở trang thông tin xác thực của nhà cung cấp và tạo khóa cho",
"OnboardApiKeyLocalHint": "Runtime cục bộ thường không cần dán khóa — khởi động máy chủ rồi nhấn Enter.",
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/locales/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@
"OnboardLanguageFooter": "按 1-9 选择,或按 Enter 保留当前设置",
"OnboardProviderTitle": "选择模型提供商",
"OnboardProviderBlurb": "选择模型运行的位置。托管提供商需要密钥;本地运行时无需密钥即可继续。",
"OnboardProviderFooter": "按 0-9 选择,↑/↓ 移动,Enter 继续,Esc 返回。",
"OnboardProviderFooter": "Enter 打开提供商列表 · Esc 返回 · Ctrl+C 退出。",
"OnboardApiKeyTitle": "连接你的 API 密钥",
"OnboardApiKeyStep1": "步骤 1. 打开你的提供商凭据页面并为",
"OnboardApiKeyLocalHint": "本地运行时通常无需粘贴密钥——启动服务器后按 Enter。",
Expand Down
89 changes: 88 additions & 1 deletion crates/tui/src/tui/provider_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1457,6 +1457,31 @@ impl ProviderPickerView {
target: Option<ApiProvider>,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
) -> Self {
Self::new_for_setup_inner(active, target, config, runtime_status, true)
}

/// Open the setup catalog for first-run/recovery onboarding (#4763).
/// Identical to [`Self::new_for_setup`] except that a missing-auth
/// `target` is only *focused*: onboarding must show the navigable
/// provider list before it asks for a secret, so key/OAuth entry is
/// reached by picking a row, never by opening straight into it.
#[must_use]
pub fn new_for_onboarding(
active: ApiProvider,
target: Option<ApiProvider>,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
) -> Self {
Self::new_for_setup_inner(active, target, config, runtime_status, false)
}

fn new_for_setup_inner(
active: ApiProvider,
target: Option<ApiProvider>,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
key_entry_for_missing_auth: bool,
) -> Self {
let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
picker.view = ProviderListView::Catalog;
Expand All @@ -1465,7 +1490,7 @@ impl ProviderPickerView {
&& let Some(idx) = picker.rows.iter().position(|row| row.provider == target)
{
picker.selected_idx = idx;
if !picker.selected_has_key() {
if key_entry_for_missing_auth && !picker.selected_has_key() {
picker.enter_key_entry();
}
}
Expand Down Expand Up @@ -5121,6 +5146,68 @@ mod tests {
assert!(picker.api_key_input.is_empty());
}

/// #4763: onboarding focuses the persisted route but must still open on
/// the navigable list. Jumping straight into key/OAuth entry hid the
/// provider catalog from returning users with a missing key.
#[test]
fn onboarding_catalog_focuses_missing_provider_without_leaving_the_list() {
let _lock = crate::test_support::lock_test_env();
let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
let config = Config::default();
let picker = ProviderPickerView::new_for_onboarding(
ApiProvider::Deepseek,
Some(ApiProvider::Anthropic),
&config,
None,
);

assert_eq!(picker.stage, Stage::List);
assert_eq!(picker.view, ProviderListView::Catalog);
assert_eq!(picker.selected_provider(), ApiProvider::Anthropic);
assert_eq!(
picker.visible_row_count(),
picker.rows.len(),
"onboarding must show the whole provider catalog"
);
}

/// #4763: Escape backs out one stage at a time — key entry returns to the
/// list, and only the list dismisses the picker.
#[test]
fn onboarding_escape_walks_key_entry_back_to_the_list_then_dismisses() {
let _lock = crate::test_support::lock_test_env();
let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
let config = Config::default();
let mut picker = ProviderPickerView::new_for_onboarding(
ApiProvider::Deepseek,
Some(ApiProvider::Anthropic),
&config,
None,
);
assert_eq!(picker.stage, Stage::List);

picker.enter_key_entry();
assert_eq!(picker.stage, Stage::KeyEntry);

assert!(matches!(
picker.handle_key(key(KeyCode::Esc)),
ViewAction::None
));
assert_eq!(
picker.stage,
Stage::List,
"Escape from key entry returns to the provider list"
);

assert!(
matches!(
picker.handle_key(key(KeyCode::Esc)),
ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed { .. })
),
"Escape from the list dismisses the picker"
);
}

#[test]
fn setup_catalog_uses_setup_title() {
let config = Config::default();
Expand Down
108 changes: 79 additions & 29 deletions crates/tui/src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,41 @@ fn back_from_api_key_onboarding(app: &mut App) {
app.status_message = None;
}

/// Where a key goes while onboarding owns the screen (#4763).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnboardingKeyRoute {
/// Terminate the session. Ctrl+C is unconditional during onboarding.
Quit,
/// Hand the key to the provider picker on the view stack.
ProviderPicker,
/// Fall through to the legacy onboarding key switch.
Legacy,
}

/// Decide the onboarding route for one key press.
///
/// Two invariants this encodes, both regressions reported in #4763:
/// Ctrl+C quits from *any* onboarding state — a modal on the stack must not
/// swallow it — and Escape is never intercepted on the picker's behalf, so
/// the picker can back out one stage at a time instead of the shell popping
/// the whole modal from a key/OAuth sub-stage.
fn onboarding_key_route(
onboarding: OnboardingState,
top_kind: Option<ModalKind>,
key: &KeyEvent,
) -> OnboardingKeyRoute {
if onboarding == OnboardingState::None {
return OnboardingKeyRoute::Legacy;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return OnboardingKeyRoute::Quit;
}
if onboarding == OnboardingState::Provider && top_kind == Some(ModalKind::ProviderPicker) {
return OnboardingKeyRoute::ProviderPicker;
}
OnboardingKeyRoute::Legacy
}

fn back_from_provider_onboarding(app: &mut App) {
if app.onboarding_missing_key_recovery {
// A returning user declined missing-key recovery: leave onboarding
Expand Down Expand Up @@ -4960,35 +4995,39 @@ async fn run_event_loop(
// parallel ten-provider key handler. Route its keys before the
// legacy onboarding switch so List/Key/Model/Confirm retain the
// same behavior as `/provider` and `/setup`.
if app.onboarding == OnboardingState::Provider
&& app.view_stack.top_kind() == Some(ModalKind::ProviderPicker)
{
if key.code == KeyCode::Esc {
// Onboarding has no committed provider choice yet. A
// single Escape always abandons the whole setup modal and
// returns to Language; do not let an inner key/model
// stage mutate config or mark onboarding complete.
app.view_stack.pop();
back_from_provider_onboarding(app);
match onboarding_key_route(app.onboarding, app.view_stack.top_kind(), &key) {
// #4763: onboarding must never be a trap. Ctrl+C terminates
// from every onboarding state, including while the picker
// owns the keys — the legacy handler below is unreachable
// once a modal is on the stack.
OnboardingKeyRoute::Quit => {
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(());
}
// Every other key, Escape included, belongs to the picker.
// The picker's own per-stage Escape walks key/OAuth entry
// back to the list and only dismisses from the list, where
// `ProviderPickerDismissed` runs the same non-mutating
// onboarding back-transition the shell used to force.
OnboardingKeyRoute::ProviderPicker => {
let events = app.view_stack.handle_key(key);
app.needs_redraw = true;
if handle_view_events_boxed(
terminal,
app,
config,
&task_manager,
&mut engine_handle,
&mut web_config_session,
events,
)
.await?
{
return Ok(());
}
continue;
}
let events = app.view_stack.handle_key(key);
app.needs_redraw = true;
if handle_view_events_boxed(
terminal,
app,
config,
&task_manager,
&mut engine_handle,
&mut web_config_session,
events,
)
.await?
{
return Ok(());
}
continue;
OnboardingKeyRoute::Legacy => {}
}

// Handle onboarding flow
Expand Down Expand Up @@ -10155,7 +10194,9 @@ async fn query_provider_runtime_status(
/// Open the one canonical provider setup surface for onboarding. Fresh
/// onboarding starts at the full catalog; missing-key recovery focuses the
/// current route so an exact Kimi Code K3 configuration can expose its plan
/// route before a secret is entered.
/// route before a secret is entered. Either way the picker opens on the
/// navigable list (#4763): onboarding never drops a user straight into a
/// key/OAuth prompt for a route they were not shown.
async fn open_onboarding_provider_picker(
app: &mut App,
config: &Config,
Expand All @@ -10169,7 +10210,7 @@ async fn open_onboarding_provider_picker(
}
let runtime_status = query_provider_runtime_status(engine_handle).await;
app.view_stack.push(
crate::tui::provider_picker::ProviderPickerView::new_for_setup(
crate::tui::provider_picker::ProviderPickerView::new_for_onboarding(
app.api_provider,
focus_current_route.then_some(app.onboarding_provider),
config,
Expand Down Expand Up @@ -13809,7 +13850,16 @@ async fn handle_view_events(
.replace("{provider}", provider.as_str());
app.push_status_toast(toast, StatusToastLevel::Success, Some(8_000));
let model_override = provider_picker_model_override(app, config, provider);
switch_provider(app, engine_handle, config, provider, model_override).await;
let switched =
switch_provider(app, engine_handle, config, provider, model_override).await;
// #4763: reusing an external CLI grant completes provider
// onboarding exactly like a submitted key or an applied
// route. Without this the picker closes on success and
// the user is returned to the provider step they just
// satisfied — the second half of the reported loop.
if switched && app.onboarding == OnboardingState::Provider {
complete_provider_picker_onboarding(app, provider);
}
refresh_config_view_if_open(app, "provider");
}
Err(error) => app.push_status_toast(
Expand Down
Loading
Loading