on main help sign in
feat: verify the API key when setting provider

* feat: simple provider api key test * feat: add api key validation * chore: add api key to models selector * chore: address review * chore: fix api key

commit1a656de03b4513218615502158ec07eeb8ef0f0e authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 18 Jul 2025, 21:59 (1 year ago) parentc8c2a9c
11 files changed+610−68
internal/config/config.go+52 internal/config/init.go+1−1 internal/config/load.go+30 internal/tui/components/chat/splash/splash.go+97−4 internal/tui/components/dialogs/models/apikey.go+129−23 internal/tui/components/dialogs/models/keys.go+12 internal/tui/components/dialogs/models/models.go+212−24 internal/tui/page/chat/chat.go+38−8 internal/tui/styles/crush.go+1 internal/tui/styles/theme.go+7−6 internal/tui/tui.go+31−2
internal/config/config.go+52
@@ -1,10 +1,13 @@
11package config
22
33import (
4+ "context"
45 "fmt"
6+ "net/http"
57 "os"
68 "slices"
79 "strings"
10+ "time"
811
912 "github.com/charmbracelet/crush/internal/env"
1013 "github.com/charmbracelet/crush/internal/fur/provider"
@@ -435,3 +438,52 @@ func (c *Config) SetupAgents() {
435438 }
436439 c.Agents = agents
437440}
441+
442+func (c *Config) Resolver() VariableResolver {
443+ return c.resolver
444+}
445+
446+func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
447+ testURL := ""
448+ headers := make(map[string]string)
449+ apiKey, _ := resolver.ResolveValue(c.APIKey)
450+ switch c.Type {
451+ case provider.TypeOpenAI:
452+ baseURL, _ := resolver.ResolveValue(c.BaseURL)
453+ if baseURL == "" {
454+ baseURL = "https://api.openai.com/v1"
455+ }
456+ testURL = baseURL + "/models"
457+ headers["Authorization"] = "Bearer " + apiKey
458+ case provider.TypeAnthropic:
459+ baseURL, _ := resolver.ResolveValue(c.BaseURL)
460+ if baseURL == "" {
461+ baseURL = "https://api.anthropic.com/v1"
462+ }
463+ testURL = baseURL + "/models"
464+ headers["x-api-key"] = apiKey
465+ headers["anthropic-version"] = "2023-06-01"
466+ }
467+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
468+ defer cancel()
469+ client := &http.Client{}
470+ req, err := http.NewRequestWithContext(ctx, "GET", testURL, nil)
471+ if err != nil {
472+ return fmt.Errorf("failed to create request for provider %s: %w", c.ID, err)
473+ }
474+ for k, v := range headers {
475+ req.Header.Set(k, v)
476+ }
477+ for k, v := range c.ExtraHeaders {
478+ req.Header.Set(k, v)
479+ }
480+ b, err := client.Do(req)
481+ if err != nil {
482+ return fmt.Errorf("failed to create request for provider %s: %w", c.ID, err)
483+ }
484+ if b.StatusCode != http.StatusOK {
485+ return fmt.Errorf("failed to connect to provider %s: %s", c.ID, b.Status)
486+ }
487+ _ = b.Body.Close()
488+ return nil
489+}
internal/config/init.go+1−1
@@ -109,5 +109,5 @@ func HasInitialDataConfig() bool {
109109 if _, err := os.Stat(cfgPath); err != nil {
110110 return false
111111 }
112− return true
112+ return Get().IsConfigured()
113113}
internal/config/load.go+30
@@ -9,6 +9,7 @@ import (
99 "runtime"
1010 "slices"
1111 "strings"
12+ "sync"
1213
1314 "github.com/charmbracelet/crush/internal/env"
1415 "github.com/charmbracelet/crush/internal/fur/client"
@@ -75,6 +76,35 @@ func Load(workingDir string, debug bool) (*Config, error) {
7576 return nil, fmt.Errorf("failed to configure providers: %w", err)
7677 }
7778
79+ // Test provider connections in parallel
80+ var testResults sync.Map
81+ var wg sync.WaitGroup
82+
83+ for _, p := range cfg.Providers {
84+ if p.Type == provider.TypeOpenAI || p.Type == provider.TypeAnthropic {
85+ wg.Add(1)
86+ go func(provider ProviderConfig) {
87+ defer wg.Done()
88+ err := provider.TestConnection(cfg.resolver)
89+ testResults.Store(provider.ID, err == nil)
90+ if err != nil {
91+ slog.Error("Provider connection test failed", "provider", provider.ID, "error", err)
92+ }
93+ }(p)
94+ }
95+ }
96+ wg.Wait()
97+
98+ // Remove failed providers
99+ testResults.Range(func(key, value any) bool {
100+ providerID := key.(string)
101+ passed := value.(bool)
102+ if !passed {
103+ delete(cfg.Providers, providerID)
104+ }
105+ return true
106+ })
107+
78108 if !cfg.IsConfigured() {
79109 slog.Warn("No providers configured")
80110 return cfg, nil
internal/tui/components/chat/splash/splash.go+97−4
@@ -5,8 +5,10 @@ import (
55 "os"
66 "slices"
77 "strings"
8+ "time"
89
910 "github.com/charmbracelet/bubbles/v2/key"
11+ "github.com/charmbracelet/bubbles/v2/spinner"
1012 tea "github.com/charmbracelet/bubbletea/v2"
1113 "github.com/charmbracelet/crush/internal/config"
1214 "github.com/charmbracelet/crush/internal/fur/provider"
@@ -36,6 +38,9 @@ type Splash interface {
3638
3739 // Showing API key input
3840 IsShowingAPIKey() bool
41+
42+ // IsAPIKeyValid returns whether the API key is valid
43+ IsAPIKeyValid() bool
3944}
4045
4146const (
@@ -45,7 +50,10 @@ const (
4550)
4651
4752// OnboardingCompleteMsg is sent when onboarding is complete
48−type OnboardingCompleteMsg struct{}
53+type (
54+ OnboardingCompleteMsg struct{}
55+ SubmitAPIKeyMsg struct{}
56+)
4957
5058type splashCmp struct {
5159 width, height int
@@ -62,6 +70,8 @@ type splashCmp struct {
6270 modelList *models.ModelListComponent
6371 apiKeyInput *models.APIKeyInput
6472 selectedModel *models.ModelOption
73+ isAPIKeyValid bool
74+ apiKeyValue string
6575}
6676
6777func New() Splash {
@@ -141,6 +151,7 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
141151 // remove padding, logo height, gap, title space
142152 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
143153 listWidth := min(60, width)
154+ s.apiKeyInput.SetWidth(width - 2)
144155 return s.modelList.SetSize(listWidth, s.listHeight)
145156}
146157
@@ -149,16 +160,38 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149160 switch msg := msg.(type) {
150161 case tea.WindowSizeMsg:
151162 return s, s.SetSize(msg.Width, msg.Height)
163+ case models.APIKeyStateChangeMsg:
164+ u, cmd := s.apiKeyInput.Update(msg)
165+ s.apiKeyInput = u.(*models.APIKeyInput)
166+ if msg.State == models.APIKeyInputStateVerified {
167+ return s, tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
168+ return SubmitAPIKeyMsg{}
169+ })
170+ }
171+ return s, cmd
172+ case SubmitAPIKeyMsg:
173+ if s.isAPIKeyValid {
174+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
175+ }
152176 case tea.KeyPressMsg:
153177 switch {
154178 case key.Matches(msg, s.keyMap.Back):
179+ if s.isAPIKeyValid {
180+ return s, nil
181+ }
155182 if s.needsAPIKey {
156183 // Go back to model selection
157184 s.needsAPIKey = false
158185 s.selectedModel = nil
186+ s.isAPIKeyValid = false
187+ s.apiKeyValue = ""
188+ s.apiKeyInput.Reset()
159189 return s, nil
160190 }
161191 case key.Matches(msg, s.keyMap.Select):
192+ if s.isAPIKeyValid {
193+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
194+ }
162195 if s.isOnboarding && !s.needsAPIKey {
163196 modelInx := s.modelList.SelectedIndex()
164197 items := s.modelList.Items()
@@ -176,23 +209,75 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
176209 }
177210 } else if s.needsAPIKey {
178211 // Handle API key submission
179− apiKey := s.apiKeyInput.Value()
180− if apiKey != "" {
181− return s, s.saveAPIKeyAndContinue(apiKey)
212+ s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
213+ if s.apiKeyValue == "" {
214+ return s, nil
182215 }
216+
217+ provider, err := s.getProvider(s.selectedModel.Provider.ID)
218+ if err != nil || provider == nil {
219+ return s, util.ReportError(fmt.Errorf("provider %s not found", s.selectedModel.Provider.ID))
220+ }
221+ providerConfig := config.ProviderConfig{
222+ ID: string(s.selectedModel.Provider.ID),
223+ Name: s.selectedModel.Provider.Name,
224+ APIKey: s.apiKeyValue,
225+ Type: provider.Type,
226+ BaseURL: provider.APIEndpoint,
227+ }
228+ return s, tea.Sequence(
229+ util.CmdHandler(models.APIKeyStateChangeMsg{
230+ State: models.APIKeyInputStateVerifying,
231+ }),
232+ func() tea.Msg {
233+ start := time.Now()
234+ err := providerConfig.TestConnection(config.Get().Resolver())
235+ // intentionally wait for at least 750ms to make sure the user sees the spinner
236+ elapsed := time.Since(start)
237+ if elapsed < 750*time.Millisecond {
238+ time.Sleep(750*time.Millisecond - elapsed)
239+ }
240+ if err == nil {
241+ s.isAPIKeyValid = true
242+ return models.APIKeyStateChangeMsg{
243+ State: models.APIKeyInputStateVerified,
244+ }
245+ }
246+ return models.APIKeyStateChangeMsg{
247+ State: models.APIKeyInputStateError,
248+ }
249+ },
250+ )
183251 } else if s.needsProjectInit {
184252 return s, s.initializeProject()
185253 }
186254 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
255+ if s.needsAPIKey {
256+ u, cmd := s.apiKeyInput.Update(msg)
257+ s.apiKeyInput = u.(*models.APIKeyInput)
258+ return s, cmd
259+ }
187260 if s.needsProjectInit {
188261 s.selectedNo = !s.selectedNo
189262 return s, nil
190263 }
191264 case key.Matches(msg, s.keyMap.Yes):
265+ if s.needsAPIKey {
266+ u, cmd := s.apiKeyInput.Update(msg)
267+ s.apiKeyInput = u.(*models.APIKeyInput)
268+ return s, cmd
269+ }
270+
192271 if s.needsProjectInit {
193272 return s, s.initializeProject()
194273 }
195274 case key.Matches(msg, s.keyMap.No):
275+ if s.needsAPIKey {
276+ u, cmd := s.apiKeyInput.Update(msg)
277+ s.apiKeyInput = u.(*models.APIKeyInput)
278+ return s, cmd
279+ }
280+
196281 s.selectedNo = true
197282 return s, s.initializeProject()
198283 default:
@@ -216,6 +301,10 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
216301 s.modelList, cmd = s.modelList.Update(msg)
217302 return s, cmd
218303 }
304+ case spinner.TickMsg:
305+ u, cmd := s.apiKeyInput.Update(msg)
306+ s.apiKeyInput = u.(*models.APIKeyInput)
307+ return s, cmd
219308 }
220309 return s, nil
221310}
@@ -629,3 +718,7 @@ func (s *splashCmp) mcpBlock() string {
629718func (s *splashCmp) IsShowingAPIKey() bool {
630719 return s.needsAPIKey
631720}
721+
722+func (s *splashCmp) IsAPIKeyValid() bool {
723+ return s.isAPIKeyValid
724+}
internal/tui/components/dialogs/models/apikey.go+129−23
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "strings"
66
7+ "github.com/charmbracelet/bubbles/v2/spinner"
78 "github.com/charmbracelet/bubbles/v2/textinput"
89 tea "github.com/charmbracelet/bubbletea/v2"
910 "github.com/charmbracelet/crush/internal/config"
@@ -11,11 +12,27 @@ import (
1112 "github.com/charmbracelet/lipgloss/v2"
1213)
1314
15+type APIKeyInputState int
16+
17+const (
18+ APIKeyInputStateInitial APIKeyInputState = iota
19+ APIKeyInputStateVerifying
20+ APIKeyInputStateVerified
21+ APIKeyInputStateError
22+)
23+
24+type APIKeyStateChangeMsg struct {
25+ State APIKeyInputState
26+}
27+
1428type APIKeyInput struct {
1529 input textinput.Model
1630 width int
17− height int
31+ spinner spinner.Model
1832 providerName string
33+ state APIKeyInputState
34+ title string
35+ showTitle bool
1936}
2037
2138func NewAPIKeyInput() *APIKeyInput {
@@ -23,32 +40,59 @@ func NewAPIKeyInput() *APIKeyInput {
2340
2441 ti := textinput.New()
2542 ti.Placeholder = "Enter your API key..."
26− ti.SetWidth(50)
2743 ti.SetVirtualCursor(false)
2844 ti.Prompt = "> "
2945 ti.SetStyles(t.S().TextInput)
3046 ti.Focus()
3147
3248 return &APIKeyInput{
33− input: ti,
34− width: 60,
49+ input: ti,
50+ state: APIKeyInputStateInitial,
51+ spinner: spinner.New(
52+ spinner.WithSpinner(spinner.Dot),
53+ spinner.WithStyle(t.S().Base.Foreground(t.Green)),
54+ ),
3555 providerName: "Provider",
56+ showTitle: true,
3657 }
3758}
3859
3960func (a *APIKeyInput) SetProviderName(name string) {
4061 a.providerName = name
62+ a.updateStatePresentation()
63+}
64+
65+func (a *APIKeyInput) SetShowTitle(show bool) {
66+ a.showTitle = show
67+}
68+
69+func (a *APIKeyInput) GetTitle() string {
70+ return a.title
4171}
4272
4373func (a *APIKeyInput) Init() tea.Cmd {
44− return textinput.Blink
74+ a.updateStatePresentation()
75+ return a.spinner.Tick
4576}
4677
4778func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4879 switch msg := msg.(type) {
49− case tea.WindowSizeMsg:
50− a.width = msg.Width
51− a.height = msg.Height
80+ case spinner.TickMsg:
81+ if a.state == APIKeyInputStateVerifying {
82+ var cmd tea.Cmd
83+ a.spinner, cmd = a.spinner.Update(msg)
84+ a.updateStatePresentation()
85+ return a, cmd
86+ }
87+ return a, nil
88+ case APIKeyStateChangeMsg:
89+ a.state = msg.State
90+ var cmd tea.Cmd
91+ if msg.State == APIKeyInputStateVerifying {
92+ cmd = a.spinner.Tick
93+ }
94+ a.updateStatePresentation()
95+ return a, cmd
5296 }
5397
5498 var cmd tea.Cmd
@@ -56,36 +100,79 @@ func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
56100 return a, cmd
57101}
58102
59−func (a *APIKeyInput) View() string {
103+func (a *APIKeyInput) updateStatePresentation() {
60104 t := styles.CurrentTheme()
61105
62− title := t.S().Base.
63− Foreground(t.Primary).
64− Bold(true).
65− Render(fmt.Sprintf("Enter your %s API Key", a.providerName))
106+ prefixStyle := t.S().Base.
107+ Foreground(t.Primary)
108+ accentStyle := t.S().Base.Foreground(t.Green).Bold(true)
109+ errorStyle := t.S().Base.Foreground(t.Cherry)
110+
111+ switch a.state {
112+ case APIKeyInputStateInitial:
113+ titlePrefix := prefixStyle.Render("Enter your ")
114+ a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(".")
115+ a.input.SetStyles(t.S().TextInput)
116+ a.input.Prompt = "> "
117+ case APIKeyInputStateVerifying:
118+ titlePrefix := prefixStyle.Render("Verifying your ")
119+ a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render("...")
120+ ts := t.S().TextInput
121+ // make the blurred state be the same
122+ ts.Blurred.Prompt = ts.Focused.Prompt
123+ a.input.Prompt = a.spinner.View()
124+ a.input.Blur()
125+ case APIKeyInputStateVerified:
126+ a.title = accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(" validated.")
127+ ts := t.S().TextInput
128+ // make the blurred state be the same
129+ ts.Blurred.Prompt = ts.Focused.Prompt
130+ a.input.SetStyles(ts)
131+ a.input.Prompt = styles.CheckIcon + " "
132+ a.input.Blur()
133+ case APIKeyInputStateError:
134+ a.title = errorStyle.Render("Invalid ") + accentStyle.Render(a.providerName+" API Key") + errorStyle.Render(". Try again?")
135+ ts := t.S().TextInput
136+ ts.Focused.Prompt = ts.Focused.Prompt.Foreground(t.Cherry)
137+ a.input.Focus()
138+ a.input.SetStyles(ts)
139+ a.input.Prompt = styles.ErrorIcon + " "
140+ }
141+}
66142
143+func (a *APIKeyInput) View() string {
67144 inputView := a.input.View()
68145
69146 dataPath := config.GlobalConfigData()
70147 dataPath = strings.Replace(dataPath, config.HomeDir(), "~", 1)
71− helpText := t.S().Muted.
148+ helpText := styles.CurrentTheme().S().Muted.
72149 Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath))
73150
74− content := lipgloss.JoinVertical(
75− lipgloss.Left,
76− title,
77− "",
78− inputView,
79− "",
80− helpText,
81− )
151+ var content string
152+ if a.showTitle && a.title != "" {
153+ content = lipgloss.JoinVertical(
154+ lipgloss.Left,
155+ a.title,
156+ "",
157+ inputView,
158+ "",
159+ helpText,
160+ )
161+ } else {
162+ content = lipgloss.JoinVertical(
163+ lipgloss.Left,
164+ inputView,
165+ "",
166+ helpText,
167+ )
168+ }
82169
83170 return content
84171}
85172
86173func (a *APIKeyInput) Cursor() *tea.Cursor {
87174 cursor := a.input.Cursor()
88− if cursor != nil {
175+ if cursor != nil && a.showTitle {
89176 cursor.Y += 2 // Adjust for title and spacing
90177 }
91178 return cursor
@@ -94,3 +181,22 @@ func (a *APIKeyInput) Cursor() *tea.Cursor {
94181func (a *APIKeyInput) Value() string {
95182 return a.input.Value()
96183}
184+
185+func (a *APIKeyInput) Tick() tea.Cmd {
186+ if a.state == APIKeyInputStateVerifying {
187+ return a.spinner.Tick
188+ }
189+ return nil
190+}
191+
192+func (a *APIKeyInput) SetWidth(width int) {
193+ a.width = width
194+ a.input.SetWidth(width - 4)
195+}
196+
197+func (a *APIKeyInput) Reset() {
198+ a.state = APIKeyInputStateInitial
199+ a.input.SetValue("")
200+ a.input.Focus()
201+ a.updateStatePresentation()
202+}
internal/tui/components/dialogs/models/keys.go+12
@@ -10,6 +10,9 @@ type KeyMap struct {
1010 Previous,
1111 Tab,
1212 Close key.Binding
13+
14+ isAPIKeyHelp bool
15+ isAPIKeyValid bool
1316}
1417
1518func DefaultKeyMap() KeyMap {
@@ -61,6 +64,15 @@ func (k KeyMap) FullHelp() [][]key.Binding {
6164
6265// ShortHelp implements help.KeyMap.
6366func (k KeyMap) ShortHelp() []key.Binding {
67+ if k.isAPIKeyHelp && !k.isAPIKeyValid {
68+ return []key.Binding{
69+ k.Close,
70+ }
71+ } else if k.isAPIKeyValid {
72+ return []key.Binding{
73+ k.Select,
74+ }
75+ }
6476 return []key.Binding{
6577 key.NewBinding(
6678 key.WithKeys("down", "up"),
internal/tui/components/dialogs/models/models.go+212−24
@@ -1,8 +1,12 @@
11package models
22
33import (
4+ "fmt"
5+ "time"
6+
47 "github.com/charmbracelet/bubbles/v2/help"
58 "github.com/charmbracelet/bubbles/v2/key"
9+ "github.com/charmbracelet/bubbles/v2/spinner"
610 tea "github.com/charmbracelet/bubbletea/v2"
711 "github.com/charmbracelet/crush/internal/config"
812 "github.com/charmbracelet/crush/internal/fur/provider"
@@ -56,6 +60,14 @@ type modelDialogCmp struct {
5660 modelList *ModelListComponent
5761 keyMap KeyMap
5862 help help.Model
63+
64+ // API key state
65+ needsAPIKey bool
66+ apiKeyInput *APIKeyInput
67+ selectedModel *ModelOption
68+ selectedModelType config.SelectedModelType
69+ isAPIKeyValid bool
70+ apiKeyValue string
5971}
6072
6173func NewModelDialogCmp() ModelDialog {
@@ -75,19 +87,22 @@ func NewModelDialogCmp() ModelDialog {
7587 t := styles.CurrentTheme()
7688 inputStyle := t.S().Base.Padding(0, 1, 0, 1)
7789 modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks")
90+ apiKeyInput := NewAPIKeyInput()
91+ apiKeyInput.SetShowTitle(false)
7892 help := help.New()
7993 help.Styles = t.S().Help
8094
8195 return &modelDialogCmp{
82− modelList: modelList,
83− width: defaultWidth,
84− keyMap: DefaultKeyMap(),
85− help: help,
96+ modelList: modelList,
97+ apiKeyInput: apiKeyInput,
98+ width: defaultWidth,
99+ keyMap: DefaultKeyMap(),
100+ help: help,
86101 }
87102}
88103
89104func (m *modelDialogCmp) Init() tea.Cmd {
90− return m.modelList.Init()
105+ return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
91106}
92107
93108func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -95,10 +110,58 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
95110 case tea.WindowSizeMsg:
96111 m.wWidth = msg.Width
97112 m.wHeight = msg.Height
113+ m.apiKeyInput.SetWidth(m.width - 2)
114+ m.help.Width = m.width - 2
98115 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
116+ case APIKeyStateChangeMsg:
117+ u, cmd := m.apiKeyInput.Update(msg)
118+ m.apiKeyInput = u.(*APIKeyInput)
119+ return m, cmd
99120 case tea.KeyPressMsg:
100121 switch {
101122 case key.Matches(msg, m.keyMap.Select):
123+ if m.isAPIKeyValid {
124+ return m, m.saveAPIKeyAndContinue(m.apiKeyValue)
125+ }
126+ if m.needsAPIKey {
127+ // Handle API key submission
128+ m.apiKeyValue = m.apiKeyInput.Value()
129+ provider, err := m.getProvider(m.selectedModel.Provider.ID)
130+ if err != nil || provider == nil {
131+ return m, util.ReportError(fmt.Errorf("provider %s not found", m.selectedModel.Provider.ID))
132+ }
133+ providerConfig := config.ProviderConfig{
134+ ID: string(m.selectedModel.Provider.ID),
135+ Name: m.selectedModel.Provider.Name,
136+ APIKey: m.apiKeyValue,
137+ Type: provider.Type,
138+ BaseURL: provider.APIEndpoint,
139+ }
140+ return m, tea.Sequence(
141+ util.CmdHandler(APIKeyStateChangeMsg{
142+ State: APIKeyInputStateVerifying,
143+ }),
144+ func() tea.Msg {
145+ start := time.Now()
146+ err := providerConfig.TestConnection(config.Get().Resolver())
147+ // intentionally wait for at least 750ms to make sure the user sees the spinner
148+ elapsed := time.Since(start)
149+ if elapsed < 750*time.Millisecond {
150+ time.Sleep(750*time.Millisecond - elapsed)
151+ }
152+ if err == nil {
153+ m.isAPIKeyValid = true
154+ return APIKeyStateChangeMsg{
155+ State: APIKeyInputStateVerified,
156+ }
157+ }
158+ return APIKeyStateChangeMsg{
159+ State: APIKeyInputStateError,
160+ }
161+ },
162+ )
163+ }
164+ // Normal model selection
102165 selectedItemInx := m.modelList.SelectedIndex()
103166 if selectedItemInx == list.NoSelection {
104167 return m, nil
@@ -113,17 +176,32 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113176 modelType = config.SelectedModelTypeSmall
114177 }
115178
116− return m, tea.Sequence(
117− util.CmdHandler(dialogs.CloseDialogMsg{}),
118− util.CmdHandler(ModelSelectedMsg{
119− Model: config.SelectedModel{
120− Model: selectedItem.Model.ID,
121− Provider: string(selectedItem.Provider.ID),
122− },
123− ModelType: modelType,
124− }),
125− )
179+ // Check if provider is configured
180+ if m.isProviderConfigured(string(selectedItem.Provider.ID)) {
181+ return m, tea.Sequence(
182+ util.CmdHandler(dialogs.CloseDialogMsg{}),
183+ util.CmdHandler(ModelSelectedMsg{
184+ Model: config.SelectedModel{
185+ Model: selectedItem.Model.ID,
186+ Provider: string(selectedItem.Provider.ID),
187+ },
188+ ModelType: modelType,
189+ }),
190+ )
191+ } else {
192+ // Provider not configured, show API key input
193+ m.needsAPIKey = true
194+ m.selectedModel = &selectedItem
195+ m.selectedModelType = modelType
196+ m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
197+ return m, nil
198+ }
126199 case key.Matches(msg, m.keyMap.Tab):
200+ if m.needsAPIKey {
201+ u, cmd := m.apiKeyInput.Update(msg)
202+ m.apiKeyInput = u.(*APIKeyInput)
203+ return m, cmd
204+ }
127205 if m.modelList.GetModelType() == LargeModelType {
128206 m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
129207 return m, m.modelList.SetModelType(SmallModelType)
@@ -132,18 +210,68 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
132210 return m, m.modelList.SetModelType(LargeModelType)
133211 }
134212 case key.Matches(msg, m.keyMap.Close):
213+ if m.needsAPIKey {
214+ if m.isAPIKeyValid {
215+ return m, nil
216+ }
217+ // Go back to model selection
218+ m.needsAPIKey = false
219+ m.selectedModel = nil
220+ m.isAPIKeyValid = false
221+ m.apiKeyValue = ""
222+ m.apiKeyInput.Reset()
223+ return m, nil
224+ }
135225 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
136226 default:
137− u, cmd := m.modelList.Update(msg)
138− m.modelList = u
227+ if m.needsAPIKey {
228+ u, cmd := m.apiKeyInput.Update(msg)
229+ m.apiKeyInput = u.(*APIKeyInput)
230+ return m, cmd
231+ } else {
232+ u, cmd := m.modelList.Update(msg)
233+ m.modelList = u
234+ return m, cmd
235+ }
236+ }
237+ case tea.PasteMsg:
238+ if m.needsAPIKey {
239+ u, cmd := m.apiKeyInput.Update(msg)
240+ m.apiKeyInput = u.(*APIKeyInput)
241+ return m, cmd
242+ } else {
243+ var cmd tea.Cmd
244+ m.modelList, cmd = m.modelList.Update(msg)
139245 return m, cmd
140246 }
247+ case spinner.TickMsg:
248+ u, cmd := m.apiKeyInput.Update(msg)
249+ m.apiKeyInput = u.(*APIKeyInput)
250+ return m, cmd
141251 }
142252 return m, nil
143253}
144254
145255func (m *modelDialogCmp) View() string {
146256 t := styles.CurrentTheme()
257+
258+ if m.needsAPIKey {
259+ // Show API key input
260+ m.keyMap.isAPIKeyHelp = true
261+ m.keyMap.isAPIKeyValid = m.isAPIKeyValid
262+ apiKeyView := m.apiKeyInput.View()
263+ apiKeyView = t.S().Base.Width(m.width - 3).Height(lipgloss.Height(apiKeyView)).PaddingLeft(1).Render(apiKeyView)
264+ content := lipgloss.JoinVertical(
265+ lipgloss.Left,
266+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(m.apiKeyInput.GetTitle(), m.width-4)),
267+ apiKeyView,
268+ "",
269+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
270+ )
271+ return m.style().Render(content)
272+ }
273+
274+ // Show model selection
147275 listView := m.modelList.View()
148276 radio := m.modelTypeRadio()
149277 content := lipgloss.JoinVertical(
@@ -157,10 +285,18 @@ func (m *modelDialogCmp) View() string {
157285}
158286
159287func (m *modelDialogCmp) Cursor() *tea.Cursor {
160− cursor := m.modelList.Cursor()
161− if cursor != nil {
162− cursor = m.moveCursor(cursor)
163− return cursor
288+ if m.needsAPIKey {
289+ cursor := m.apiKeyInput.Cursor()
290+ if cursor != nil {
291+ cursor = m.moveCursor(cursor)
292+ return cursor
293+ }
294+ } else {
295+ cursor := m.modelList.Cursor()
296+ if cursor != nil {
297+ cursor = m.moveCursor(cursor)
298+ return cursor
299+ }
164300 }
165301 return nil
166302}
@@ -192,9 +328,15 @@ func (m *modelDialogCmp) Position() (int, int) {
192328
193329func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
194330 row, col := m.Position()
195− offset := row + 3 // Border + title
196− cursor.Y += offset
197− cursor.X = cursor.X + col + 2
331+ if m.needsAPIKey {
332+ offset := row + 3 // Border + title + API key input offset
333+ cursor.Y += offset
334+ cursor.X = cursor.X + col + 2
335+ } else {
336+ offset := row + 3 // Border + title
337+ cursor.Y += offset
338+ cursor.X = cursor.X + col + 2
339+ }
198340 return cursor
199341}
200342
@@ -212,3 +354,49 @@ func (m *modelDialogCmp) modelTypeRadio() string {
212354 }
213355 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
214356}
357+
358+func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
359+ cfg := config.Get()
360+ if _, ok := cfg.Providers[providerID]; ok {
361+ return true
362+ }
363+ return false
364+}
365+
366+func (m *modelDialogCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
367+ providers, err := config.Providers()
368+ if err != nil {
369+ return nil, err
370+ }
371+ for _, p := range providers {
372+ if p.ID == providerID {
373+ return &p, nil
374+ }
375+ }
376+ return nil, nil
377+}
378+
379+func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
380+ if m.selectedModel == nil {
381+ return util.ReportError(fmt.Errorf("no model selected"))
382+ }
383+
384+ cfg := config.Get()
385+ err := cfg.SetProviderAPIKey(string(m.selectedModel.Provider.ID), apiKey)
386+ if err != nil {
387+ return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
388+ }
389+
390+ // Reset API key state and continue with model selection
391+ selectedModel := *m.selectedModel
392+ return tea.Sequence(
393+ util.CmdHandler(dialogs.CloseDialogMsg{}),
394+ util.CmdHandler(ModelSelectedMsg{
395+ Model: config.SelectedModel{
396+ Model: selectedModel.Model.ID,
397+ Provider: string(selectedModel.Provider.ID),
398+ },
399+ ModelType: m.selectedModelType,
400+ }),
401+ )
402+}
internal/tui/page/chat/chat.go+38−8
@@ -25,6 +25,7 @@ import (
2525 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
2626 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
2727 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
28+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
2829 "github.com/charmbracelet/crush/internal/tui/page"
2930 "github.com/charmbracelet/crush/internal/tui/styles"
3031 "github.com/charmbracelet/crush/internal/tui/util"
@@ -172,6 +173,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172173 return p, p.sendMessage(msg.Text, msg.Attachments)
173174 case chat.SessionSelectedMsg:
174175 return p, p.setSession(msg)
176+ case splash.SubmitAPIKeyMsg:
177+ u, cmd := p.splash.Update(msg)
178+ p.splash = u.(splash.Splash)
179+ cmds = append(cmds, cmd)
180+ return p, tea.Batch(cmds...)
175181 case commands.ToggleCompactModeMsg:
176182 p.forceCompact = !p.forceCompact
177183 var cmd tea.Cmd
@@ -212,12 +218,25 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
212218 cmds = append(cmds, cmd)
213219 return p, tea.Batch(cmds...)
214220
221+ case models.APIKeyStateChangeMsg:
222+ if p.focusedPane == PanelTypeSplash {
223+ u, cmd := p.splash.Update(msg)
224+ p.splash = u.(splash.Splash)
225+ cmds = append(cmds, cmd)
226+ }
227+ return p, tea.Batch(cmds...)
215228 case pubsub.Event[message.Message],
216229 anim.StepMsg,
217230 spinner.TickMsg:
218− u, cmd := p.chat.Update(msg)
219− p.chat = u.(chat.MessageListCmp)
220− cmds = append(cmds, cmd)
231+ if p.focusedPane == PanelTypeSplash {
232+ u, cmd := p.splash.Update(msg)
233+ p.splash = u.(splash.Splash)
234+ cmds = append(cmds, cmd)
235+ } else {
236+ u, cmd := p.chat.Update(msg)
237+ p.chat = u.(chat.MessageListCmp)
238+ cmds = append(cmds, cmd)
239+ }
221240 return p, tea.Batch(cmds...)
222241
223242 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
@@ -655,12 +674,23 @@ func (p *chatPage) Help() help.KeyMap {
655674 fullList = append(fullList, []key.Binding{v})
656675 }
657676 case p.isOnboarding && p.splash.IsShowingAPIKey():
677+ if p.splash.IsAPIKeyValid() {
678+ shortList = append(shortList,
679+ key.NewBinding(
680+ key.WithKeys("enter"),
681+ key.WithHelp("enter", "continue"),
682+ ),
683+ )
684+ } else {
685+ shortList = append(shortList,
686+ // Go back
687+ key.NewBinding(
688+ key.WithKeys("esc"),
689+ key.WithHelp("esc", "back"),
690+ ),
691+ )
692+ }
658693 shortList = append(shortList,
659− // Go back
660− key.NewBinding(
661− key.WithKeys("esc"),
662− key.WithHelp("esc", "back"),
663− ),
664694 // Quit
665695 key.NewBinding(
666696 key.WithKeys("ctrl+c"),
internal/tui/styles/crush.go+1
@@ -52,5 +52,6 @@ func NewCrushTheme() *Theme {
5252 Red: charmtone.Coral,
5353 RedDark: charmtone.Sriracha,
5454 RedLight: charmtone.Salmon,
55+ Cherry: charmtone.Cherry,
5556 }
5657}
internal/tui/styles/theme.go+7−6
@@ -72,6 +72,7 @@ type Theme struct {
7272 Red color.Color
7373 RedDark color.Color
7474 RedLight color.Color
75+ Cherry color.Color
7576
7677 styles *Styles
7778}
@@ -150,15 +151,15 @@ func (t *Theme) buildStyles() *Styles {
150151 TextInput: textinput.Styles{
151152 Focused: textinput.StyleState{
152153 Text: base,
153− Placeholder: base.Foreground(t.FgMuted),
154+ Placeholder: base.Foreground(t.FgSubtle),
154155 Prompt: base.Foreground(t.Tertiary),
155− Suggestion: base.Foreground(t.FgMuted),
156+ Suggestion: base.Foreground(t.FgSubtle),
156157 },
157158 Blurred: textinput.StyleState{
158159 Text: base.Foreground(t.FgMuted),
159− Placeholder: base.Foreground(t.FgMuted),
160+ Placeholder: base.Foreground(t.FgSubtle),
160161 Prompt: base.Foreground(t.FgMuted),
161− Suggestion: base.Foreground(t.FgMuted),
162+ Suggestion: base.Foreground(t.FgSubtle),
162163 },
163164 Cursor: textinput.CursorStyle{
164165 Color: t.Secondary,
@@ -173,7 +174,7 @@ func (t *Theme) buildStyles() *Styles {
173174 LineNumber: base.Foreground(t.FgSubtle),
174175 CursorLine: base,
175176 CursorLineNumber: base.Foreground(t.FgSubtle),
176− Placeholder: base.Foreground(t.FgMuted),
177+ Placeholder: base.Foreground(t.FgSubtle),
177178 Prompt: base.Foreground(t.Tertiary),
178179 },
179180 Blurred: textarea.StyleState{
@@ -182,7 +183,7 @@ func (t *Theme) buildStyles() *Styles {
182183 LineNumber: base.Foreground(t.FgMuted),
183184 CursorLine: base,
184185 CursorLineNumber: base.Foreground(t.FgMuted),
185− Placeholder: base.Foreground(t.FgMuted),
186+ Placeholder: base.Foreground(t.FgSubtle),
186187 Prompt: base.Foreground(t.FgMuted),
187188 },
188189 Cursor: textarea.CursorStyle{
internal/tui/tui.go+31−2
@@ -12,6 +12,7 @@ import (
1212 "github.com/charmbracelet/crush/internal/permission"
1313 "github.com/charmbracelet/crush/internal/pubsub"
1414 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
15+ "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
1516 "github.com/charmbracelet/crush/internal/tui/components/completions"
1617 "github.com/charmbracelet/crush/internal/tui/components/core"
1718 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
@@ -48,8 +49,9 @@ type appModel struct {
4849
4950 app *app.App
5051
51− dialog dialogs.DialogCmp
52− completions completions.Completions
52+ dialog dialogs.DialogCmp
53+ completions completions.Completions
54+ isConfigured bool
5355
5456 // Chat Page Specific
5557 selectedSessionID string // The ID of the currently selected session
@@ -72,6 +74,7 @@ func (a appModel) Init() tea.Cmd {
7274func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7375 var cmds []tea.Cmd
7476 var cmd tea.Cmd
77+ a.isConfigured = config.HasInitialDataConfig()
7578
7679 switch msg := msg.(type) {
7780 case tea.KeyboardEnhancementsMsg:
@@ -223,10 +226,28 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
223226 }
224227 }
225228
229+ return a, tea.Batch(cmds...)
230+ case splash.OnboardingCompleteMsg:
231+ a.isConfigured = config.HasInitialDataConfig()
232+ updated, cmd := a.pages[a.currentPage].Update(msg)
233+ a.pages[a.currentPage] = updated.(util.Model)
234+ cmds = append(cmds, cmd)
226235 return a, tea.Batch(cmds...)
227236 // Key Press Messages
228237 case tea.KeyPressMsg:
229238 return a, a.handleKeyPressMsg(msg)
239+
240+ case tea.PasteMsg:
241+ if a.dialog.HasDialogs() {
242+ u, dialogCmd := a.dialog.Update(msg)
243+ a.dialog = u.(dialogs.DialogCmp)
244+ cmds = append(cmds, dialogCmd)
245+ } else {
246+ updated, cmd := a.pages[a.currentPage].Update(msg)
247+ a.pages[a.currentPage] = updated.(util.Model)
248+ cmds = append(cmds, cmd)
249+ }
250+ return a, tea.Batch(cmds...)
230251 }
231252 s, _ := a.status.Update(msg)
232253 a.status = s.(status.StatusCmp)
@@ -307,6 +328,10 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
307328 })
308329
309330 case key.Matches(msg, a.keyMap.Commands):
331+ // if the app is not configured show no commands
332+ if !a.isConfigured {
333+ return nil
334+ }
310335 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
311336 return util.CmdHandler(dialogs.CloseDialogMsg{})
312337 }
@@ -317,6 +342,10 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
317342 Model: commands.NewCommandDialog(a.selectedSessionID),
318343 })
319344 case key.Matches(msg, a.keyMap.Sessions):
345+ // if the app is not configured show no sessions
346+ if !a.isConfigured {
347+ return nil
348+ }
320349 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
321350 return util.CmdHandler(dialogs.CloseDialogMsg{})
322351 }