1package splash
2
3import (
4 "fmt"
5 "os"
6 "slices"
7 "strings"
8 "time"
9
10 "github.com/charmbracelet/bubbles/v2/key"
11 "github.com/charmbracelet/bubbles/v2/spinner"
12 tea "github.com/charmbracelet/bubbletea/v2"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/fur/provider"
15 "github.com/charmbracelet/crush/internal/llm/prompt"
16 "github.com/charmbracelet/crush/internal/tui/components/chat"
17 "github.com/charmbracelet/crush/internal/tui/components/completions"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
19 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20 "github.com/charmbracelet/crush/internal/tui/components/core/list"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
22 "github.com/charmbracelet/crush/internal/tui/components/logo"
23 "github.com/charmbracelet/crush/internal/tui/styles"
24 "github.com/charmbracelet/crush/internal/tui/util"
25 "github.com/charmbracelet/crush/internal/version"
26 "github.com/charmbracelet/lipgloss/v2"
27)
28
29type Splash interface {
30 util.Model
31 layout.Sizeable
32 layout.Help
33 Cursor() *tea.Cursor
34 // SetOnboarding controls whether the splash shows model selection UI
35 SetOnboarding(bool)
36 // SetProjectInit controls whether the splash shows project initialization prompt
37 SetProjectInit(bool)
38
39 // Showing API key input
40 IsShowingAPIKey() bool
41
42 // IsAPIKeyValid returns whether the API key is valid
43 IsAPIKeyValid() bool
44}
45
46const (
47 SplashScreenPaddingY = 1 // Padding Y for the splash screen
48
49 LogoGap = 6
50)
51
52// OnboardingCompleteMsg is sent when onboarding is complete
53type (
54 OnboardingCompleteMsg struct{}
55 SubmitAPIKeyMsg struct{}
56)
57
58type splashCmp struct {
59 width, height int
60 keyMap KeyMap
61 logoRendered string
62
63 // State
64 isOnboarding bool
65 needsProjectInit bool
66 needsAPIKey bool
67 selectedNo bool
68
69 listHeight int
70 modelList *models.ModelListComponent
71 apiKeyInput *models.APIKeyInput
72 selectedModel *models.ModelOption
73 isAPIKeyValid bool
74 apiKeyValue string
75}
76
77func New() Splash {
78 keyMap := DefaultKeyMap()
79 listKeyMap := list.DefaultKeyMap()
80 listKeyMap.Down.SetEnabled(false)
81 listKeyMap.Up.SetEnabled(false)
82 listKeyMap.HalfPageDown.SetEnabled(false)
83 listKeyMap.HalfPageUp.SetEnabled(false)
84 listKeyMap.Home.SetEnabled(false)
85 listKeyMap.End.SetEnabled(false)
86 listKeyMap.DownOneItem = keyMap.Next
87 listKeyMap.UpOneItem = keyMap.Previous
88
89 t := styles.CurrentTheme()
90 inputStyle := t.S().Base.Padding(0, 1, 0, 1)
91 modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave")
92 apiKeyInput := models.NewAPIKeyInput()
93
94 return &splashCmp{
95 width: 0,
96 height: 0,
97 keyMap: keyMap,
98 logoRendered: "",
99 modelList: modelList,
100 apiKeyInput: apiKeyInput,
101 selectedNo: false,
102 }
103}
104
105func (s *splashCmp) SetOnboarding(onboarding bool) {
106 s.isOnboarding = onboarding
107 if onboarding {
108 providers, err := config.Providers()
109 if err != nil {
110 return
111 }
112 filteredProviders := []provider.Provider{}
113 simpleProviders := []string{
114 "anthropic",
115 "openai",
116 "gemini",
117 "xai",
118 "groq",
119 "openrouter",
120 }
121 for _, p := range providers {
122 if slices.Contains(simpleProviders, string(p.ID)) {
123 filteredProviders = append(filteredProviders, p)
124 }
125 }
126 s.modelList.SetProviders(filteredProviders)
127 }
128}
129
130func (s *splashCmp) SetProjectInit(needsInit bool) {
131 s.needsProjectInit = needsInit
132}
133
134// GetSize implements SplashPage.
135func (s *splashCmp) GetSize() (int, int) {
136 return s.width, s.height
137}
138
139// Init implements SplashPage.
140func (s *splashCmp) Init() tea.Cmd {
141 return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init())
142}
143
144// SetSize implements SplashPage.
145func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
146 s.height = height
147 if width != s.width {
148 s.width = width
149 s.logoRendered = s.logoBlock()
150 }
151 // remove padding, logo height, gap, title space
152 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
153 listWidth := min(60, width)
154 s.apiKeyInput.SetWidth(width - 2)
155 return s.modelList.SetSize(listWidth, s.listHeight)
156}
157
158// Update implements SplashPage.
159func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
160 switch msg := msg.(type) {
161 case tea.WindowSizeMsg:
162 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 }
176 case tea.KeyPressMsg:
177 switch {
178 case key.Matches(msg, s.keyMap.Back):
179 if s.isAPIKeyValid {
180 return s, nil
181 }
182 if s.needsAPIKey {
183 // Go back to model selection
184 s.needsAPIKey = false
185 s.selectedModel = nil
186 s.isAPIKeyValid = false
187 s.apiKeyValue = ""
188 s.apiKeyInput.Reset()
189 return s, nil
190 }
191 case key.Matches(msg, s.keyMap.Select):
192 if s.isAPIKeyValid {
193 return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
194 }
195 if s.isOnboarding && !s.needsAPIKey {
196 modelInx := s.modelList.SelectedIndex()
197 items := s.modelList.Items()
198 selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
199 if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
200 cmd := s.setPreferredModel(selectedItem)
201 s.isOnboarding = false
202 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
203 } else {
204 // Provider not configured, show API key input
205 s.needsAPIKey = true
206 s.selectedModel = &selectedItem
207 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
208 return s, nil
209 }
210 } else if s.needsAPIKey {
211 // Handle API key submission
212 s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
213 if s.apiKeyValue == "" {
214 return s, nil
215 }
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 )
251 } else if s.needsProjectInit {
252 return s, s.initializeProject()
253 }
254 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 }
260 if s.needsProjectInit {
261 s.selectedNo = !s.selectedNo
262 return s, nil
263 }
264 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
271 if s.needsProjectInit {
272 return s, s.initializeProject()
273 }
274 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
281 s.selectedNo = true
282 return s, s.initializeProject()
283 default:
284 if s.needsAPIKey {
285 u, cmd := s.apiKeyInput.Update(msg)
286 s.apiKeyInput = u.(*models.APIKeyInput)
287 return s, cmd
288 } else if s.isOnboarding {
289 u, cmd := s.modelList.Update(msg)
290 s.modelList = u
291 return s, cmd
292 }
293 }
294 case tea.PasteMsg:
295 if s.needsAPIKey {
296 u, cmd := s.apiKeyInput.Update(msg)
297 s.apiKeyInput = u.(*models.APIKeyInput)
298 return s, cmd
299 } else if s.isOnboarding {
300 var cmd tea.Cmd
301 s.modelList, cmd = s.modelList.Update(msg)
302 return s, cmd
303 }
304 case spinner.TickMsg:
305 u, cmd := s.apiKeyInput.Update(msg)
306 s.apiKeyInput = u.(*models.APIKeyInput)
307 return s, cmd
308 }
309 return s, nil
310}
311
312func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
313 if s.selectedModel == nil {
314 return util.ReportError(fmt.Errorf("no model selected"))
315 }
316
317 cfg := config.Get()
318 err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
319 if err != nil {
320 return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
321 }
322
323 // Reset API key state and continue with model selection
324 s.needsAPIKey = false
325 cmd := s.setPreferredModel(*s.selectedModel)
326 s.isOnboarding = false
327 s.selectedModel = nil
328
329 return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
330}
331
332func (s *splashCmp) initializeProject() tea.Cmd {
333 s.needsProjectInit = false
334
335 if err := config.MarkProjectInitialized(); err != nil {
336 return util.ReportError(err)
337 }
338 var cmds []tea.Cmd
339
340 cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{}))
341 if !s.selectedNo {
342 cmds = append(cmds,
343 util.CmdHandler(chat.SessionClearedMsg{}),
344 util.CmdHandler(chat.SendMsg{
345 Text: prompt.Initialize(),
346 }),
347 )
348 }
349 return tea.Sequence(cmds...)
350}
351
352func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
353 cfg := config.Get()
354 model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
355 if model == nil {
356 return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
357 }
358
359 selectedModel := config.SelectedModel{
360 Model: selectedItem.Model.ID,
361 Provider: string(selectedItem.Provider.ID),
362 ReasoningEffort: model.DefaultReasoningEffort,
363 MaxTokens: model.DefaultMaxTokens,
364 }
365
366 err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
367 if err != nil {
368 return util.ReportError(err)
369 }
370
371 // Now lets automatically setup the small model
372 knownProvider, err := s.getProvider(selectedItem.Provider.ID)
373 if err != nil {
374 return util.ReportError(err)
375 }
376 if knownProvider == nil {
377 // for local provider we just use the same model
378 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
379 if err != nil {
380 return util.ReportError(err)
381 }
382 } else {
383 smallModel := knownProvider.DefaultSmallModelID
384 model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
385 // should never happen
386 if model == nil {
387 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
388 if err != nil {
389 return util.ReportError(err)
390 }
391 return nil
392 }
393 smallSelectedModel := config.SelectedModel{
394 Model: smallModel,
395 Provider: string(selectedItem.Provider.ID),
396 ReasoningEffort: model.DefaultReasoningEffort,
397 MaxTokens: model.DefaultMaxTokens,
398 }
399 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
400 if err != nil {
401 return util.ReportError(err)
402 }
403 }
404 cfg.SetupAgents()
405 return nil
406}
407
408func (s *splashCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
409 providers, err := config.Providers()
410 if err != nil {
411 return nil, err
412 }
413 for _, p := range providers {
414 if p.ID == providerID {
415 return &p, nil
416 }
417 }
418 return nil, nil
419}
420
421func (s *splashCmp) isProviderConfigured(providerID string) bool {
422 cfg := config.Get()
423 if _, ok := cfg.Providers[providerID]; ok {
424 return true
425 }
426 return false
427}
428
429func (s *splashCmp) View() string {
430 t := styles.CurrentTheme()
431 var content string
432 if s.needsAPIKey {
433 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
434 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
435 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
436 lipgloss.JoinVertical(
437 lipgloss.Left,
438 apiKeyView,
439 ),
440 )
441 content = lipgloss.JoinVertical(
442 lipgloss.Left,
443 s.logoRendered,
444 apiKeySelector,
445 )
446 } else if s.isOnboarding {
447 modelListView := s.modelList.View()
448 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
449 modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
450 lipgloss.JoinVertical(
451 lipgloss.Left,
452 t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Choose a Model"),
453 "",
454 modelListView,
455 ),
456 )
457 content = lipgloss.JoinVertical(
458 lipgloss.Left,
459 s.logoRendered,
460 modelSelector,
461 )
462 } else if s.needsProjectInit {
463 titleStyle := t.S().Base.Foreground(t.FgBase)
464 bodyStyle := t.S().Base.Foreground(t.FgMuted)
465 shortcutStyle := t.S().Base.Foreground(t.Success)
466
467 initText := lipgloss.JoinVertical(
468 lipgloss.Left,
469 titleStyle.Render("Would you like to initialize this project?"),
470 "",
471 bodyStyle.Render("When I initialize your codebase I examine the project and put the"),
472 bodyStyle.Render("result into a CRUSH.md file which serves as general context."),
473 "",
474 bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."),
475 "",
476 bodyStyle.Render("Would you like to initialize now?"),
477 )
478
479 yesButton := core.SelectableButton(core.ButtonOpts{
480 Text: "Yep!",
481 UnderlineIndex: 0,
482 Selected: !s.selectedNo,
483 })
484
485 noButton := core.SelectableButton(core.ButtonOpts{
486 Text: "Nope",
487 UnderlineIndex: 0,
488 Selected: s.selectedNo,
489 })
490
491 buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton)
492 infoSection := s.infoSection()
493
494 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - lipgloss.Height(infoSection)
495
496 initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render(
497 lipgloss.JoinVertical(
498 lipgloss.Left,
499 initText,
500 "",
501 buttons,
502 ),
503 )
504
505 content = lipgloss.JoinVertical(
506 lipgloss.Left,
507 s.logoRendered,
508 infoSection,
509 initContent,
510 )
511 } else {
512 parts := []string{
513 s.logoRendered,
514 s.infoSection(),
515 }
516 content = lipgloss.JoinVertical(lipgloss.Left, parts...)
517 }
518
519 return t.S().Base.
520 Width(s.width).
521 Height(s.height).
522 PaddingTop(SplashScreenPaddingY).
523 PaddingBottom(SplashScreenPaddingY).
524 Render(content)
525}
526
527func (s *splashCmp) Cursor() *tea.Cursor {
528 if s.needsAPIKey {
529 cursor := s.apiKeyInput.Cursor()
530 if cursor != nil {
531 return s.moveCursor(cursor)
532 }
533 } else if s.isOnboarding {
534 cursor := s.modelList.Cursor()
535 if cursor != nil {
536 return s.moveCursor(cursor)
537 }
538 } else {
539 return nil
540 }
541 return nil
542}
543
544func (s *splashCmp) infoSection() string {
545 t := styles.CurrentTheme()
546 return t.S().Base.PaddingLeft(2).Render(
547 lipgloss.JoinVertical(
548 lipgloss.Left,
549 s.cwd(),
550 "",
551 lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
552 "",
553 ),
554 )
555}
556
557func (s *splashCmp) logoBlock() string {
558 t := styles.CurrentTheme()
559 return t.S().Base.Padding(0, 2).Width(s.width).Render(
560 logo.Render(version.Version, false, logo.Opts{
561 FieldColor: t.Primary,
562 TitleColorA: t.Secondary,
563 TitleColorB: t.Primary,
564 CharmColor: t.Secondary,
565 VersionColor: t.Primary,
566 Width: s.width - 4,
567 }),
568 )
569}
570
571func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
572 if cursor == nil {
573 return nil
574 }
575 // Calculate the correct Y offset based on current state
576 logoHeight := lipgloss.Height(s.logoRendered)
577 if s.needsAPIKey {
578 infoSectionHeight := lipgloss.Height(s.infoSection())
579 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
580 remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
581 offset := baseOffset + remainingHeight
582 cursor.Y += offset
583 cursor.X = cursor.X + 1
584 } else if s.isOnboarding {
585 offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
586 cursor.Y += offset
587 cursor.X = cursor.X + 1
588 }
589
590 return cursor
591}
592
593func (s *splashCmp) logoGap() int {
594 if s.height > 35 {
595 return LogoGap
596 }
597 return 0
598}
599
600// Bindings implements SplashPage.
601func (s *splashCmp) Bindings() []key.Binding {
602 if s.needsAPIKey {
603 return []key.Binding{
604 s.keyMap.Select,
605 s.keyMap.Back,
606 }
607 } else if s.isOnboarding {
608 return []key.Binding{
609 s.keyMap.Select,
610 s.keyMap.Next,
611 s.keyMap.Previous,
612 }
613 } else if s.needsProjectInit {
614 return []key.Binding{
615 s.keyMap.Select,
616 s.keyMap.Yes,
617 s.keyMap.No,
618 s.keyMap.Tab,
619 s.keyMap.LeftRight,
620 }
621 }
622 return []key.Binding{}
623}
624
625func (s *splashCmp) getMaxInfoWidth() int {
626 return min(s.width-2, 40) // 2 for left padding
627}
628
629func (s *splashCmp) cwd() string {
630 cwd := config.Get().WorkingDir()
631 t := styles.CurrentTheme()
632 homeDir, err := os.UserHomeDir()
633 if err == nil && cwd != homeDir {
634 cwd = strings.ReplaceAll(cwd, homeDir, "~")
635 }
636 maxWidth := s.getMaxInfoWidth()
637 return t.S().Muted.Width(maxWidth).Render(cwd)
638}
639
640func LSPList(maxWidth int) []string {
641 t := styles.CurrentTheme()
642 lspList := []string{}
643 lsp := config.Get().LSP.Sorted()
644 if len(lsp) == 0 {
645 return []string{t.S().Base.Foreground(t.Border).Render("None")}
646 }
647 for _, l := range lsp {
648 iconColor := t.Success
649 if l.LSP.Disabled {
650 iconColor = t.FgMuted
651 }
652 lspList = append(lspList,
653 core.Status(
654 core.StatusOpts{
655 IconColor: iconColor,
656 Title: l.Name,
657 Description: l.LSP.Command,
658 },
659 maxWidth,
660 ),
661 )
662 }
663 return lspList
664}
665
666func (s *splashCmp) lspBlock() string {
667 t := styles.CurrentTheme()
668 maxWidth := s.getMaxInfoWidth() / 2
669 section := t.S().Subtle.Render("LSPs")
670 lspList := append([]string{section, ""}, LSPList(maxWidth-1)...)
671 return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
672 lipgloss.JoinVertical(
673 lipgloss.Left,
674 lspList...,
675 ),
676 )
677}
678
679func MCPList(maxWidth int) []string {
680 t := styles.CurrentTheme()
681 mcpList := []string{}
682 mcps := config.Get().MCP.Sorted()
683 if len(mcps) == 0 {
684 return []string{t.S().Base.Foreground(t.Border).Render("None")}
685 }
686 for _, l := range mcps {
687 iconColor := t.Success
688 if l.MCP.Disabled {
689 iconColor = t.FgMuted
690 }
691 mcpList = append(mcpList,
692 core.Status(
693 core.StatusOpts{
694 IconColor: iconColor,
695 Title: l.Name,
696 Description: l.MCP.Command,
697 },
698 maxWidth,
699 ),
700 )
701 }
702 return mcpList
703}
704
705func (s *splashCmp) mcpBlock() string {
706 t := styles.CurrentTheme()
707 maxWidth := s.getMaxInfoWidth() / 2
708 section := t.S().Subtle.Render("MCPs")
709 mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...)
710 return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
711 lipgloss.JoinVertical(
712 lipgloss.Left,
713 mcpList...,
714 ),
715 )
716}
717
718func (s *splashCmp) IsShowingAPIKey() bool {
719 return s.needsAPIKey
720}
721
722func (s *splashCmp) IsAPIKeyValid() bool {
723 return s.isAPIKeyValid
724}