import { Box, Text, useInput } from 'ink'; import SelectInput from 'ink-select-input'; import TextInput from 'ink-text-input'; import Spinner from 'ink-spinner'; import React, { useCallback, useState } from 'react'; import type { Config, ProviderName } from '../config'; import { fetchModels, PRESETS, readClaudeCodeSettings, type ProviderPreset } from '../providers'; export type OnboardResult = { presetId: string; provider: ProviderName; baseURL: string; apiKey: string; model: string; }; type Step = | { name: 'pick-provider' } | { name: 'base-url'; preset: ProviderPreset } | { name: 'api-key'; preset: ProviderPreset; baseURL: string } | { name: 'loading'; preset: ProviderPreset; baseURL: string; apiKey: string } | { name: 'pick-model'; preset: ProviderPreset; baseURL: string; apiKey: string; models: string[]; warning?: string } | { name: 'type-model'; preset: ProviderPreset; baseURL: string; apiKey: string; warning?: string }; const mask = (key: string) => (key.length <= 8 ? '*'.repeat(key.length) : `${key.slice(0, 4)}...${key.slice(-4)}`); const MANUAL_ENTRY = '__type_it__'; /** * Provider onboarding: pick a preset, supply a key, then choose a model from the * server's own /models list. Rendered in place of the prompt input, so it owns * the keyboard while open. */ export function Onboard({ current, onDone, onCancel, }: { current: Config; onDone: (result: OnboardResult) => void; onCancel: () => void; }) { const [step, setStep] = useState({ name: 'pick-provider' }); const [draft, setDraft] = useState(''); useInput( (_input, key) => { if (key.escape) onCancel(); }, { isActive: step.name !== 'loading' }, ); const loadModels = useCallback( async (preset: ProviderPreset, baseURL: string, apiKey: string) => { setStep({ name: 'loading', preset, baseURL, apiKey }); const { models, warning } = await fetchModels({ ...preset, baseURL }, apiKey); setDraft(''); if (models.length === 0) { setStep({ name: 'type-model', preset, baseURL, apiKey, ...(warning ? { warning } : {}) }); } else { setStep({ name: 'pick-model', preset, baseURL, apiKey, models, ...(warning ? { warning } : {}) }); } }, [], ); const afterBaseUrl = useCallback( (preset: ProviderPreset, baseURL: string, settingsKey?: string) => { const fromEnv = preset.envKey ? process.env[preset.envKey] : undefined; const key = preset.keyless ? 'local' : (settingsKey ?? fromEnv ?? ''); if (key) return void loadModels(preset, baseURL, key); setDraft(''); setStep({ name: 'api-key', preset, baseURL }); }, [loadModels], ); const pickProvider = useCallback( (preset: ProviderPreset) => { // The claude-code preset has no fixed endpoint: read the base URL and key // from ~/.claude/settings.json, falling back to asking either if the file // is absent or lacks the variable. if (preset.baseURL) return afterBaseUrl(preset, preset.baseURL); void readClaudeCodeSettings().then(({ baseURL, apiKey }) => baseURL ? afterBaseUrl(preset, baseURL, apiKey) : setStep({ name: 'base-url', preset }), ); setDraft(''); }, [afterBaseUrl], ); switch (step.name) { case 'pick-provider': { const items = PRESETS.map((p) => ({ key: p.id, label: p.id === current.presetId ? `${p.label} (current)` : p.label, value: p.id, })); return ( p.id === (current.presetId ?? current.provider)), )} onSelect={(item) => { const preset = PRESETS.find((p) => p.id === item.value); if (preset) pickProvider(preset); }} /> ); } case 'base-url': return ( v.trim() && afterBaseUrl(step.preset, v.trim())} placeholder="https://..." /> ); case 'api-key': return ( v.trim() && void loadModels(step.preset, step.baseURL, v.trim())} placeholder={step.preset.keyHint ?? 'paste it here'} /> ); case 'loading': return ( GET {step.baseURL}/models ); case 'pick-model': { const items = [ ...step.models.map((m) => ({ key: m, label: m, value: m })), { key: MANUAL_ENTRY, label: 'type a model id myself...', value: MANUAL_ENTRY }, ]; return ( { if (item.value === MANUAL_ENTRY) { setDraft(''); setStep({ name: 'type-model', preset: step.preset, baseURL: step.baseURL, apiKey: step.apiKey }); return; } onDone({ presetId: step.preset.id, provider: step.preset.kind, baseURL: step.baseURL, apiKey: step.apiKey, model: item.value, }); }} /> ); } case 'type-model': return ( v.trim() && onDone({ presetId: step.preset.id, provider: step.preset.kind, baseURL: step.baseURL, apiKey: step.apiKey, model: v.trim(), }) } placeholder="model-id" /> ); } } function Frame({ title, hint, warning, children, }: { title: string; hint?: string; warning?: string; children: React.ReactNode; }) { return ( {title} {hint && {hint}} {warning && could not list models: {warning}} {children} ); } function Row({ label, children }: { label: string; children: React.ReactNode }) { return ( {label}: {children} ); }