splash.go

  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}