1package splash
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "charm.land/bubbles/v2/key"
9 "charm.land/bubbles/v2/spinner"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/atotto/clipboard"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
14 "github.com/charmbracelet/crush/internal/agent"
15 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
16 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/home"
18 "github.com/charmbracelet/crush/internal/tui/components/chat"
19 "github.com/charmbracelet/crush/internal/tui/components/core"
20 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
25 "github.com/charmbracelet/crush/internal/tui/components/logo"
26 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
27 "github.com/charmbracelet/crush/internal/tui/components/mcp"
28 "github.com/charmbracelet/crush/internal/tui/exp/list"
29 "github.com/charmbracelet/crush/internal/tui/styles"
30 "github.com/charmbracelet/crush/internal/tui/util"
31 "github.com/charmbracelet/crush/internal/version"
32)
33
34type Splash interface {
35 util.Model
36 layout.Sizeable
37 layout.Help
38 Cursor() *tea.Cursor
39 // SetOnboarding controls whether the splash shows model selection UI
40 SetOnboarding(bool)
41 // SetProjectInit controls whether the splash shows project initialization prompt
42 SetProjectInit(bool)
43
44 // Showing API key input
45 IsShowingAPIKey() bool
46
47 // IsAPIKeyValid returns whether the API key is valid
48 IsAPIKeyValid() bool
49
50 // IsShowingClaudeAuthMethodChooser returns whether showing Claude auth method chooser
51 IsShowingClaudeAuthMethodChooser() bool
52
53 // IsShowingClaudeOAuth2 returns whether showing Claude OAuth2 flow
54 IsShowingClaudeOAuth2() bool
55
56 // IsClaudeOAuthURLState returns whether in OAuth URL state
57 IsClaudeOAuthURLState() bool
58
59 // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete
60 IsClaudeOAuthComplete() bool
61
62 // IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow
63 IsShowingHyperOAuth2() bool
64
65 // IsShowingClaudeOAuth2 returns whether showing GitHub Copilot OAuth2 flow
66 IsShowingCopilotOAuth2() bool
67}
68
69const (
70 SplashScreenPaddingY = 1 // Padding Y for the splash screen
71
72 LogoGap = 6
73)
74
75// OnboardingCompleteMsg is sent when onboarding is complete
76type (
77 OnboardingCompleteMsg struct{}
78 SubmitAPIKeyMsg struct{}
79)
80
81type splashCmp struct {
82 width, height int
83 keyMap KeyMap
84 logoRendered string
85
86 // State
87 isOnboarding bool
88 needsProjectInit bool
89 needsAPIKey bool
90 selectedNo bool
91
92 listHeight int
93 modelList *models.ModelListComponent
94 apiKeyInput *models.APIKeyInput
95 selectedModel *models.ModelOption
96 isAPIKeyValid bool
97 apiKeyValue string
98
99 // Hyper device flow state
100 hyperDeviceFlow *hyper.DeviceFlow
101 showHyperDeviceFlow bool
102
103 // Copilot device flow state
104 copilotDeviceFlow *copilot.DeviceFlow
105 showCopilotDeviceFlow bool
106
107 // Claude state
108 claudeAuthMethodChooser *claude.AuthMethodChooser
109 claudeOAuth2 *claude.OAuth2
110 showClaudeAuthMethodChooser bool
111 showClaudeOAuth2 bool
112}
113
114func New() Splash {
115 keyMap := DefaultKeyMap()
116 listKeyMap := list.DefaultKeyMap()
117 listKeyMap.Down.SetEnabled(false)
118 listKeyMap.Up.SetEnabled(false)
119 listKeyMap.HalfPageDown.SetEnabled(false)
120 listKeyMap.HalfPageUp.SetEnabled(false)
121 listKeyMap.Home.SetEnabled(false)
122 listKeyMap.End.SetEnabled(false)
123 listKeyMap.DownOneItem = keyMap.Next
124 listKeyMap.UpOneItem = keyMap.Previous
125
126 modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
127 apiKeyInput := models.NewAPIKeyInput()
128
129 return &splashCmp{
130 width: 0,
131 height: 0,
132 keyMap: keyMap,
133 logoRendered: "",
134 modelList: modelList,
135 apiKeyInput: apiKeyInput,
136 selectedNo: false,
137
138 claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
139 claudeOAuth2: claude.NewOAuth2(),
140 }
141}
142
143func (s *splashCmp) SetOnboarding(onboarding bool) {
144 s.isOnboarding = onboarding
145}
146
147func (s *splashCmp) SetProjectInit(needsInit bool) {
148 s.needsProjectInit = needsInit
149}
150
151// GetSize implements SplashPage.
152func (s *splashCmp) GetSize() (int, int) {
153 return s.width, s.height
154}
155
156// Init implements SplashPage.
157func (s *splashCmp) Init() tea.Cmd {
158 return tea.Batch(
159 s.modelList.Init(),
160 s.apiKeyInput.Init(),
161 s.claudeAuthMethodChooser.Init(),
162 s.claudeOAuth2.Init(),
163 )
164}
165
166// SetSize implements SplashPage.
167func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
168 wasSmallScreen := s.isSmallScreen()
169 rerenderLogo := width != s.width
170 s.height = height
171 s.width = width
172 if rerenderLogo || wasSmallScreen != s.isSmallScreen() {
173 s.logoRendered = s.logoBlock()
174 }
175 // remove padding, logo height, gap, title space
176 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
177 listWidth := min(60, width)
178 s.apiKeyInput.SetWidth(width - 2)
179 s.claudeAuthMethodChooser.SetWidth(min(width-2, 60))
180 return s.modelList.SetSize(listWidth, s.listHeight)
181}
182
183// Update implements SplashPage.
184func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
185 switch msg := msg.(type) {
186 case tea.WindowSizeMsg:
187 return s, s.SetSize(msg.Width, msg.Height)
188 case claude.ValidationCompletedMsg:
189 var cmds []tea.Cmd
190 u, cmd := s.claudeOAuth2.Update(msg)
191 s.claudeOAuth2 = u.(*claude.OAuth2)
192 cmds = append(cmds, cmd)
193
194 if msg.State == claude.OAuthValidationStateValid {
195 cmds = append(
196 cmds,
197 s.saveAPIKeyAndContinue(msg.Token, false),
198 func() tea.Msg {
199 time.Sleep(5 * time.Second)
200 return claude.AuthenticationCompleteMsg{}
201 },
202 )
203 }
204
205 return s, tea.Batch(cmds...)
206 case hyper.DeviceFlowCompletedMsg:
207 s.showHyperDeviceFlow = false
208 return s, s.saveAPIKeyAndContinue(msg.Token, true)
209 case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg:
210 if s.hyperDeviceFlow != nil {
211 u, cmd := s.hyperDeviceFlow.Update(msg)
212 s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
213 return s, cmd
214 }
215 return s, nil
216 case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
217 if s.copilotDeviceFlow != nil {
218 u, cmd := s.copilotDeviceFlow.Update(msg)
219 s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
220 return s, cmd
221 }
222 return s, nil
223 case copilot.DeviceFlowCompletedMsg:
224 s.showCopilotDeviceFlow = false
225 return s, s.saveAPIKeyAndContinue(msg.Token, true)
226 case claude.AuthenticationCompleteMsg:
227 s.showClaudeAuthMethodChooser = false
228 s.showClaudeOAuth2 = false
229 return s, util.CmdHandler(OnboardingCompleteMsg{})
230 case models.APIKeyStateChangeMsg:
231 u, cmd := s.apiKeyInput.Update(msg)
232 s.apiKeyInput = u.(*models.APIKeyInput)
233 if msg.State == models.APIKeyInputStateVerified {
234 return s, tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
235 return SubmitAPIKeyMsg{}
236 })
237 }
238 return s, cmd
239 case SubmitAPIKeyMsg:
240 if s.isAPIKeyValid {
241 return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
242 }
243 case tea.KeyPressMsg:
244 switch {
245 case key.Matches(msg, s.keyMap.Copy) && s.showHyperDeviceFlow:
246 return s, s.hyperDeviceFlow.CopyCode()
247 case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow:
248 return s, s.copilotDeviceFlow.CopyCode()
249 case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL:
250 return s, tea.Sequence(
251 tea.SetClipboard(s.claudeOAuth2.URL),
252 func() tea.Msg {
253 _ = clipboard.WriteAll(s.claudeOAuth2.URL)
254 return nil
255 },
256 util.ReportInfo("URL copied to clipboard"),
257 )
258 case key.Matches(msg, s.keyMap.Copy) && s.showClaudeAuthMethodChooser:
259 u, cmd := s.claudeAuthMethodChooser.Update(msg)
260 s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
261 return s, cmd
262 case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2:
263 u, cmd := s.claudeOAuth2.Update(msg)
264 s.claudeOAuth2 = u.(*claude.OAuth2)
265 return s, cmd
266 case key.Matches(msg, s.keyMap.Back):
267 switch {
268 case s.showClaudeAuthMethodChooser:
269 s.claudeAuthMethodChooser.SetDefaults()
270 s.showClaudeAuthMethodChooser = false
271 return s, nil
272 case s.showClaudeOAuth2:
273 s.claudeOAuth2.SetDefaults()
274 s.showClaudeOAuth2 = false
275 s.showClaudeAuthMethodChooser = true
276 return s, nil
277 case s.showHyperDeviceFlow:
278 s.hyperDeviceFlow = nil
279 s.showHyperDeviceFlow = false
280 return s, nil
281 case s.showCopilotDeviceFlow:
282 s.copilotDeviceFlow = nil
283 s.showCopilotDeviceFlow = false
284 return s, nil
285 case s.isAPIKeyValid:
286 return s, nil
287 case s.needsAPIKey:
288 if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
289 s.showClaudeAuthMethodChooser = true
290 }
291 s.needsAPIKey = false
292 s.selectedModel = nil
293 s.isAPIKeyValid = false
294 s.apiKeyValue = ""
295 s.apiKeyInput.Reset()
296 return s, nil
297 }
298 case key.Matches(msg, s.keyMap.Select):
299 switch {
300 case s.showClaudeAuthMethodChooser:
301 selectedItem := s.modelList.SelectedModel()
302 if selectedItem == nil {
303 return s, nil
304 }
305
306 switch s.claudeAuthMethodChooser.State {
307 case claude.AuthMethodAPIKey:
308 s.showClaudeAuthMethodChooser = false
309 s.needsAPIKey = true
310 s.selectedModel = selectedItem
311 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
312 case claude.AuthMethodOAuth2:
313 s.selectedModel = selectedItem
314 s.showClaudeAuthMethodChooser = false
315 s.showClaudeOAuth2 = true
316 }
317 return s, nil
318 case s.showClaudeOAuth2:
319 m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
320 s.claudeOAuth2 = m2.(*claude.OAuth2)
321 return s, cmd2
322 case s.showHyperDeviceFlow:
323 return s, s.hyperDeviceFlow.CopyCodeAndOpenURL()
324 case s.showCopilotDeviceFlow:
325 return s, s.copilotDeviceFlow.CopyCodeAndOpenURL()
326 case s.isAPIKeyValid:
327 return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
328 case s.isOnboarding && !s.needsAPIKey:
329 selectedItem := s.modelList.SelectedModel()
330 if selectedItem == nil {
331 return s, nil
332 }
333 if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
334 cmd := s.setPreferredModel(*selectedItem)
335 s.isOnboarding = false
336 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
337 } else {
338 switch selectedItem.Provider.ID {
339 case catwalk.InferenceProviderAnthropic:
340 s.showClaudeAuthMethodChooser = true
341 return s, nil
342 case hyperp.Name:
343 s.selectedModel = selectedItem
344 s.showHyperDeviceFlow = true
345 s.hyperDeviceFlow = hyper.NewDeviceFlow()
346 s.hyperDeviceFlow.SetWidth(min(s.width-2, 60))
347 return s, s.hyperDeviceFlow.Init()
348 case catwalk.InferenceProviderCopilot:
349 if token, ok := config.Get().ImportCopilot(); ok {
350 s.selectedModel = selectedItem
351 return s, s.saveAPIKeyAndContinue(token, true)
352 }
353 s.selectedModel = selectedItem
354 s.showCopilotDeviceFlow = true
355 s.copilotDeviceFlow = copilot.NewDeviceFlow()
356 s.copilotDeviceFlow.SetWidth(min(s.width-2, 60))
357 return s, s.copilotDeviceFlow.Init()
358 }
359 // Provider not configured, show API key input
360 s.needsAPIKey = true
361 s.selectedModel = selectedItem
362 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
363 return s, nil
364 }
365 case s.needsAPIKey:
366 // Handle API key submission
367 s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
368 if s.apiKeyValue == "" {
369 return s, nil
370 }
371
372 provider, err := s.getProvider(s.selectedModel.Provider.ID)
373 if err != nil || provider == nil {
374 return s, util.ReportError(fmt.Errorf("provider %s not found", s.selectedModel.Provider.ID))
375 }
376 providerConfig := config.ProviderConfig{
377 ID: string(s.selectedModel.Provider.ID),
378 Name: s.selectedModel.Provider.Name,
379 APIKey: s.apiKeyValue,
380 Type: provider.Type,
381 BaseURL: provider.APIEndpoint,
382 }
383 return s, tea.Sequence(
384 util.CmdHandler(models.APIKeyStateChangeMsg{
385 State: models.APIKeyInputStateVerifying,
386 }),
387 func() tea.Msg {
388 start := time.Now()
389 err := providerConfig.TestConnection(config.Get().Resolver())
390 // intentionally wait for at least 750ms to make sure the user sees the spinner
391 elapsed := time.Since(start)
392 if elapsed < 750*time.Millisecond {
393 time.Sleep(750*time.Millisecond - elapsed)
394 }
395 if err == nil {
396 s.isAPIKeyValid = true
397 return models.APIKeyStateChangeMsg{
398 State: models.APIKeyInputStateVerified,
399 }
400 }
401 return models.APIKeyStateChangeMsg{
402 State: models.APIKeyInputStateError,
403 }
404 },
405 )
406 case s.needsProjectInit:
407 return s, s.initializeProject()
408 }
409 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
410 if s.showClaudeAuthMethodChooser {
411 s.claudeAuthMethodChooser.ToggleChoice()
412 return s, nil
413 }
414 if s.needsAPIKey {
415 u, cmd := s.apiKeyInput.Update(msg)
416 s.apiKeyInput = u.(*models.APIKeyInput)
417 return s, cmd
418 }
419 if s.needsProjectInit {
420 s.selectedNo = !s.selectedNo
421 return s, nil
422 }
423 case key.Matches(msg, s.keyMap.Yes):
424 if s.needsAPIKey {
425 u, cmd := s.apiKeyInput.Update(msg)
426 s.apiKeyInput = u.(*models.APIKeyInput)
427 return s, cmd
428 }
429 if s.isOnboarding {
430 u, cmd := s.modelList.Update(msg)
431 s.modelList = u
432 return s, cmd
433 }
434 if s.needsProjectInit {
435 s.selectedNo = false
436 return s, s.initializeProject()
437 }
438 case key.Matches(msg, s.keyMap.No):
439 if s.needsAPIKey {
440 u, cmd := s.apiKeyInput.Update(msg)
441 s.apiKeyInput = u.(*models.APIKeyInput)
442 return s, cmd
443 }
444 if s.isOnboarding {
445 u, cmd := s.modelList.Update(msg)
446 s.modelList = u
447 return s, cmd
448 }
449 if s.needsProjectInit {
450 s.selectedNo = true
451 return s, s.initializeProject()
452 }
453 default:
454 switch {
455 case s.showClaudeAuthMethodChooser:
456 u, cmd := s.claudeAuthMethodChooser.Update(msg)
457 s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
458 return s, cmd
459 case s.showClaudeOAuth2:
460 u, cmd := s.claudeOAuth2.Update(msg)
461 s.claudeOAuth2 = u.(*claude.OAuth2)
462 return s, cmd
463 case s.showHyperDeviceFlow:
464 u, cmd := s.hyperDeviceFlow.Update(msg)
465 s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
466 return s, cmd
467 case s.showCopilotDeviceFlow:
468 u, cmd := s.copilotDeviceFlow.Update(msg)
469 s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
470 return s, cmd
471 case s.needsAPIKey:
472 u, cmd := s.apiKeyInput.Update(msg)
473 s.apiKeyInput = u.(*models.APIKeyInput)
474 return s, cmd
475 case s.isOnboarding:
476 u, cmd := s.modelList.Update(msg)
477 s.modelList = u
478 return s, cmd
479 }
480 }
481 case tea.PasteMsg:
482 switch {
483 case s.showClaudeOAuth2:
484 u, cmd := s.claudeOAuth2.Update(msg)
485 s.claudeOAuth2 = u.(*claude.OAuth2)
486 return s, cmd
487 case s.showHyperDeviceFlow:
488 u, cmd := s.hyperDeviceFlow.Update(msg)
489 s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
490 return s, cmd
491 case s.showCopilotDeviceFlow:
492 u, cmd := s.copilotDeviceFlow.Update(msg)
493 s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
494 return s, cmd
495 case s.needsAPIKey:
496 u, cmd := s.apiKeyInput.Update(msg)
497 s.apiKeyInput = u.(*models.APIKeyInput)
498 return s, cmd
499 case s.isOnboarding:
500 var cmd tea.Cmd
501 s.modelList, cmd = s.modelList.Update(msg)
502 return s, cmd
503 }
504 case spinner.TickMsg:
505 switch {
506 case s.showClaudeOAuth2:
507 u, cmd := s.claudeOAuth2.Update(msg)
508 s.claudeOAuth2 = u.(*claude.OAuth2)
509 return s, cmd
510 case s.showHyperDeviceFlow:
511 u, cmd := s.hyperDeviceFlow.Update(msg)
512 s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
513 return s, cmd
514 case s.showCopilotDeviceFlow:
515 u, cmd := s.copilotDeviceFlow.Update(msg)
516 s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
517 return s, cmd
518 default:
519 u, cmd := s.apiKeyInput.Update(msg)
520 s.apiKeyInput = u.(*models.APIKeyInput)
521 return s, cmd
522 }
523 }
524 return s, nil
525}
526
527func (s *splashCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
528 if s.selectedModel == nil {
529 return nil
530 }
531
532 cfg := config.Get()
533 err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
534 if err != nil {
535 return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
536 }
537
538 // Reset API key state and continue with model selection
539 s.needsAPIKey = false
540 cmd := s.setPreferredModel(*s.selectedModel)
541 s.isOnboarding = false
542 s.selectedModel = nil
543 s.isAPIKeyValid = false
544
545 if close {
546 return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
547 }
548 return cmd
549}
550
551func (s *splashCmp) initializeProject() tea.Cmd {
552 s.needsProjectInit = false
553
554 if err := config.MarkProjectInitialized(); err != nil {
555 return util.ReportError(err)
556 }
557 var cmds []tea.Cmd
558
559 cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{}))
560 if !s.selectedNo {
561 initPrompt, err := agent.InitializePrompt(*config.Get())
562 if err != nil {
563 return util.ReportError(err)
564 }
565 cmds = append(cmds,
566 util.CmdHandler(chat.SessionClearedMsg{}),
567 util.CmdHandler(chat.SendMsg{
568 Text: initPrompt,
569 }),
570 )
571 }
572 return tea.Sequence(cmds...)
573}
574
575func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
576 cfg := config.Get()
577 model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
578 if model == nil {
579 return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
580 }
581
582 selectedModel := config.SelectedModel{
583 Model: selectedItem.Model.ID,
584 Provider: string(selectedItem.Provider.ID),
585 ReasoningEffort: model.DefaultReasoningEffort,
586 MaxTokens: model.DefaultMaxTokens,
587 }
588
589 err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
590 if err != nil {
591 return util.ReportError(err)
592 }
593
594 // Now lets automatically setup the small model
595 knownProvider, err := s.getProvider(selectedItem.Provider.ID)
596 if err != nil {
597 return util.ReportError(err)
598 }
599 if knownProvider == nil {
600 // for local provider we just use the same model
601 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
602 if err != nil {
603 return util.ReportError(err)
604 }
605 } else {
606 smallModel := knownProvider.DefaultSmallModelID
607 model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
608 // should never happen
609 if model == nil {
610 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
611 if err != nil {
612 return util.ReportError(err)
613 }
614 return nil
615 }
616 smallSelectedModel := config.SelectedModel{
617 Model: smallModel,
618 Provider: string(selectedItem.Provider.ID),
619 ReasoningEffort: model.DefaultReasoningEffort,
620 MaxTokens: model.DefaultMaxTokens,
621 }
622 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
623 if err != nil {
624 return util.ReportError(err)
625 }
626 }
627 cfg.SetupAgents()
628 return nil
629}
630
631func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
632 cfg := config.Get()
633 providers, err := config.Providers(cfg)
634 if err != nil {
635 return nil, err
636 }
637 for _, p := range providers {
638 if p.ID == providerID {
639 return &p, nil
640 }
641 }
642 return nil, nil
643}
644
645func (s *splashCmp) isProviderConfigured(providerID string) bool {
646 cfg := config.Get()
647 if _, ok := cfg.Providers.Get(providerID); ok {
648 return true
649 }
650 return false
651}
652
653func (s *splashCmp) View() string {
654 t := styles.CurrentTheme()
655 var content string
656
657 switch {
658 case s.showClaudeAuthMethodChooser:
659 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
660 chooserView := s.claudeAuthMethodChooser.View()
661 authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
662 lipgloss.JoinVertical(
663 lipgloss.Left,
664 t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
665 "",
666 chooserView,
667 ),
668 )
669 content = lipgloss.JoinVertical(
670 lipgloss.Left,
671 s.logoRendered,
672 authMethodSelector,
673 )
674 case s.showClaudeOAuth2:
675 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
676 oauth2View := s.claudeOAuth2.View()
677 oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
678 lipgloss.JoinVertical(
679 lipgloss.Left,
680 t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
681 "",
682 oauth2View,
683 ),
684 )
685 content = lipgloss.JoinVertical(
686 lipgloss.Left,
687 s.logoRendered,
688 oauthSelector,
689 )
690 case s.showHyperDeviceFlow:
691 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
692 hyperView := s.hyperDeviceFlow.View()
693 hyperSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
694 lipgloss.JoinVertical(
695 lipgloss.Left,
696 t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Hyper"),
697 hyperView,
698 ),
699 )
700 content = lipgloss.JoinVertical(
701 lipgloss.Left,
702 s.logoRendered,
703 hyperSelector,
704 )
705 case s.showCopilotDeviceFlow:
706 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
707 copilotView := s.copilotDeviceFlow.View()
708 copilotSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
709 lipgloss.JoinVertical(
710 lipgloss.Left,
711 t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth GitHub Copilot"),
712 copilotView,
713 ),
714 )
715 content = lipgloss.JoinVertical(
716 lipgloss.Left,
717 s.logoRendered,
718 copilotSelector,
719 )
720 case s.needsAPIKey:
721 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
722 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
723 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
724 lipgloss.JoinVertical(
725 lipgloss.Left,
726 apiKeyView,
727 ),
728 )
729 content = lipgloss.JoinVertical(
730 lipgloss.Left,
731 s.logoRendered,
732 apiKeySelector,
733 )
734 case s.isOnboarding:
735 modelListView := s.modelList.View()
736 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
737 modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
738 lipgloss.JoinVertical(
739 lipgloss.Left,
740 t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("To start, let’s choose a provider and model."),
741 "",
742 modelListView,
743 ),
744 )
745 content = lipgloss.JoinVertical(
746 lipgloss.Left,
747 s.logoRendered,
748 modelSelector,
749 )
750 case s.needsProjectInit:
751 titleStyle := t.S().Base.Foreground(t.FgBase)
752 pathStyle := t.S().Base.Foreground(t.Success).PaddingLeft(2)
753 bodyStyle := t.S().Base.Foreground(t.FgMuted)
754 shortcutStyle := t.S().Base.Foreground(t.Success)
755
756 initFile := config.Get().Options.InitializeAs
757 initText := lipgloss.JoinVertical(
758 lipgloss.Left,
759 titleStyle.Render("Would you like to initialize this project?"),
760 "",
761 pathStyle.Render(s.cwd()),
762 "",
763 bodyStyle.Render("When I initialize your codebase I examine the project and put the"),
764 bodyStyle.Render(fmt.Sprintf("result into an %s file which serves as general context.", initFile)),
765 "",
766 bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."),
767 "",
768 bodyStyle.Render("Would you like to initialize now?"),
769 )
770
771 yesButton := core.SelectableButton(core.ButtonOpts{
772 Text: "Yep!",
773 UnderlineIndex: 0,
774 Selected: !s.selectedNo,
775 })
776
777 noButton := core.SelectableButton(core.ButtonOpts{
778 Text: "Nope",
779 UnderlineIndex: 0,
780 Selected: s.selectedNo,
781 })
782
783 buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton)
784 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
785
786 initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render(
787 lipgloss.JoinVertical(
788 lipgloss.Left,
789 initText,
790 "",
791 buttons,
792 ),
793 )
794
795 content = lipgloss.JoinVertical(
796 lipgloss.Left,
797 s.logoRendered,
798 "",
799 initContent,
800 )
801 default:
802 parts := []string{
803 s.logoRendered,
804 s.infoSection(),
805 }
806 content = lipgloss.JoinVertical(lipgloss.Left, parts...)
807 }
808
809 return t.S().Base.
810 Width(s.width).
811 Height(s.height).
812 PaddingTop(SplashScreenPaddingY).
813 PaddingBottom(SplashScreenPaddingY).
814 Render(content)
815}
816
817func (s *splashCmp) Cursor() *tea.Cursor {
818 switch {
819 case s.showClaudeAuthMethodChooser:
820 return nil
821 case s.showClaudeOAuth2:
822 if cursor := s.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
823 cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
824 return s.moveCursor(cursor)
825 }
826 return nil
827 case s.needsAPIKey:
828 cursor := s.apiKeyInput.Cursor()
829 if cursor != nil {
830 return s.moveCursor(cursor)
831 }
832 case s.isOnboarding:
833 cursor := s.modelList.Cursor()
834 if cursor != nil {
835 return s.moveCursor(cursor)
836 }
837 }
838 return nil
839}
840
841func (s *splashCmp) isSmallScreen() bool {
842 // Consider a screen small if either the width is less than 40 or if the
843 // height is less than 20
844 return s.width < 55 || s.height < 20
845}
846
847func (s *splashCmp) infoSection() string {
848 t := styles.CurrentTheme()
849 infoStyle := t.S().Base.PaddingLeft(2)
850 if s.isSmallScreen() {
851 infoStyle = infoStyle.MarginTop(1)
852 }
853 return infoStyle.Render(
854 lipgloss.JoinVertical(
855 lipgloss.Left,
856 s.cwdPart(),
857 "",
858 s.currentModelBlock(),
859 "",
860 lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
861 "",
862 ),
863 )
864}
865
866func (s *splashCmp) logoBlock() string {
867 t := styles.CurrentTheme()
868 logoStyle := t.S().Base.Padding(0, 2).Width(s.width)
869 if s.isSmallScreen() {
870 // If the width is too small, render a smaller version of the logo
871 // NOTE: 20 is not correct because [splashCmp.height] is not the
872 // *actual* window height, instead, it is the height of the splash
873 // component and that depends on other variables like compact mode and
874 // the height of the editor.
875 return logoStyle.Render(
876 logo.SmallRender(s.width - logoStyle.GetHorizontalFrameSize()),
877 )
878 }
879 return logoStyle.Render(
880 logo.Render(version.Version, false, logo.Opts{
881 FieldColor: t.Primary,
882 TitleColorA: t.Secondary,
883 TitleColorB: t.Primary,
884 CharmColor: t.Secondary,
885 VersionColor: t.Primary,
886 Width: s.width - logoStyle.GetHorizontalFrameSize(),
887 }),
888 )
889}
890
891func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
892 if cursor == nil {
893 return nil
894 }
895 // Calculate the correct Y offset based on current state
896 logoHeight := lipgloss.Height(s.logoRendered)
897 if s.needsAPIKey || s.showClaudeOAuth2 {
898 var view string
899 if s.needsAPIKey {
900 view = s.apiKeyInput.View()
901 } else {
902 view = s.claudeOAuth2.View()
903 }
904 infoSectionHeight := lipgloss.Height(s.infoSection())
905 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
906 remainingHeight := s.height - baseOffset - lipgloss.Height(view) - SplashScreenPaddingY
907 offset := baseOffset + remainingHeight
908 cursor.Y += offset
909 cursor.X += 1
910 } else if s.isOnboarding {
911 offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
912 cursor.Y += offset
913 cursor.X += 1
914 }
915
916 return cursor
917}
918
919func (s *splashCmp) logoGap() int {
920 if s.height > 35 {
921 return LogoGap
922 }
923 return 0
924}
925
926// Bindings implements SplashPage.
927func (s *splashCmp) Bindings() []key.Binding {
928 switch {
929 case s.showClaudeAuthMethodChooser:
930 return []key.Binding{
931 s.keyMap.Select,
932 s.keyMap.Tab,
933 s.keyMap.Back,
934 }
935 case s.showClaudeOAuth2:
936 bindings := []key.Binding{
937 s.keyMap.Select,
938 }
939 if s.claudeOAuth2.State == claude.OAuthStateURL {
940 bindings = append(bindings, s.keyMap.Copy)
941 }
942 return bindings
943 case s.needsAPIKey:
944 return []key.Binding{
945 s.keyMap.Select,
946 s.keyMap.Back,
947 }
948 case s.isOnboarding:
949 return []key.Binding{
950 s.keyMap.Select,
951 s.keyMap.Next,
952 s.keyMap.Previous,
953 }
954 case s.needsProjectInit:
955 return []key.Binding{
956 s.keyMap.Select,
957 s.keyMap.Yes,
958 s.keyMap.No,
959 s.keyMap.Tab,
960 s.keyMap.LeftRight,
961 }
962 default:
963 return []key.Binding{}
964 }
965}
966
967func (s *splashCmp) getMaxInfoWidth() int {
968 return min(s.width-2, 90) // 2 for left padding
969}
970
971func (s *splashCmp) cwdPart() string {
972 t := styles.CurrentTheme()
973 maxWidth := s.getMaxInfoWidth()
974 return t.S().Muted.Width(maxWidth).Render(s.cwd())
975}
976
977func (s *splashCmp) cwd() string {
978 return home.Short(config.Get().WorkingDir())
979}
980
981func LSPList(maxWidth int) []string {
982 return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{
983 MaxWidth: maxWidth,
984 ShowSection: false,
985 })
986}
987
988func (s *splashCmp) lspBlock() string {
989 t := styles.CurrentTheme()
990 maxWidth := s.getMaxInfoWidth() / 2
991 section := t.S().Subtle.Render("LSPs")
992 lspList := append([]string{section, ""}, LSPList(maxWidth-1)...)
993 return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
994 lipgloss.JoinVertical(
995 lipgloss.Left,
996 lspList...,
997 ),
998 )
999}
1000
1001func MCPList(maxWidth int) []string {
1002 return mcp.RenderMCPList(mcp.RenderOptions{
1003 MaxWidth: maxWidth,
1004 ShowSection: false,
1005 })
1006}
1007
1008func (s *splashCmp) mcpBlock() string {
1009 t := styles.CurrentTheme()
1010 maxWidth := s.getMaxInfoWidth() / 2
1011 section := t.S().Subtle.Render("MCPs")
1012 mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...)
1013 return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
1014 lipgloss.JoinVertical(
1015 lipgloss.Left,
1016 mcpList...,
1017 ),
1018 )
1019}
1020
1021func (s *splashCmp) currentModelBlock() string {
1022 cfg := config.Get()
1023 agentCfg := cfg.Agents[config.AgentCoder]
1024 model := config.Get().GetModelByType(agentCfg.Model)
1025 if model == nil {
1026 return ""
1027 }
1028 t := styles.CurrentTheme()
1029 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
1030 modelName := t.S().Text.Render(model.Name)
1031 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
1032 parts := []string{
1033 modelInfo,
1034 }
1035
1036 return lipgloss.JoinVertical(
1037 lipgloss.Left,
1038 parts...,
1039 )
1040}
1041
1042func (s *splashCmp) IsShowingAPIKey() bool {
1043 return s.needsAPIKey
1044}
1045
1046func (s *splashCmp) IsAPIKeyValid() bool {
1047 return s.isAPIKeyValid
1048}
1049
1050func (s *splashCmp) IsShowingClaudeAuthMethodChooser() bool {
1051 return s.showClaudeAuthMethodChooser
1052}
1053
1054func (s *splashCmp) IsShowingClaudeOAuth2() bool {
1055 return s.showClaudeOAuth2
1056}
1057
1058func (s *splashCmp) IsClaudeOAuthURLState() bool {
1059 return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL
1060}
1061
1062func (s *splashCmp) IsClaudeOAuthComplete() bool {
1063 return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid
1064}
1065
1066func (s *splashCmp) IsShowingHyperOAuth2() bool {
1067 return s.showHyperDeviceFlow
1068}
1069
1070func (s *splashCmp) IsShowingCopilotOAuth2() bool {
1071 return s.showCopilotDeviceFlow
1072}