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/catwalk/pkg/catwalk"
 14	"github.com/charmbracelet/crush/internal/config"
 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 := []catwalk.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	wasSmallScreen := s.isSmallScreen()
147	rerenderLogo := width != s.width
148	s.height = height
149	s.width = width
150	if rerenderLogo || wasSmallScreen != s.isSmallScreen() {
151		s.logoRendered = s.logoBlock()
152	}
153	// remove padding, logo height, gap, title space
154	s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
155	listWidth := min(60, width)
156	s.apiKeyInput.SetWidth(width - 2)
157	return s.modelList.SetSize(listWidth, s.listHeight)
158}
159
160// Update implements SplashPage.
161func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
162	switch msg := msg.(type) {
163	case tea.WindowSizeMsg:
164		return s, s.SetSize(msg.Width, msg.Height)
165	case models.APIKeyStateChangeMsg:
166		u, cmd := s.apiKeyInput.Update(msg)
167		s.apiKeyInput = u.(*models.APIKeyInput)
168		if msg.State == models.APIKeyInputStateVerified {
169			return s, tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
170				return SubmitAPIKeyMsg{}
171			})
172		}
173		return s, cmd
174	case SubmitAPIKeyMsg:
175		if s.isAPIKeyValid {
176			return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
177		}
178	case tea.KeyPressMsg:
179		switch {
180		case key.Matches(msg, s.keyMap.Back):
181			if s.isAPIKeyValid {
182				return s, nil
183			}
184			if s.needsAPIKey {
185				// Go back to model selection
186				s.needsAPIKey = false
187				s.selectedModel = nil
188				s.isAPIKeyValid = false
189				s.apiKeyValue = ""
190				s.apiKeyInput.Reset()
191				return s, nil
192			}
193		case key.Matches(msg, s.keyMap.Select):
194			if s.isAPIKeyValid {
195				return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
196			}
197			if s.isOnboarding && !s.needsAPIKey {
198				modelInx := s.modelList.SelectedIndex()
199				if modelInx == -1 {
200					return s, nil
201				}
202				items := s.modelList.Items()
203				selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
204				if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
205					cmd := s.setPreferredModel(selectedItem)
206					s.isOnboarding = false
207					return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
208				} else {
209					// Provider not configured, show API key input
210					s.needsAPIKey = true
211					s.selectedModel = &selectedItem
212					s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
213					return s, nil
214				}
215			} else if s.needsAPIKey {
216				// Handle API key submission
217				s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
218				if s.apiKeyValue == "" {
219					return s, nil
220				}
221
222				provider, err := s.getProvider(s.selectedModel.Provider.ID)
223				if err != nil || provider == nil {
224					return s, util.ReportError(fmt.Errorf("provider %s not found", s.selectedModel.Provider.ID))
225				}
226				providerConfig := config.ProviderConfig{
227					ID:      string(s.selectedModel.Provider.ID),
228					Name:    s.selectedModel.Provider.Name,
229					APIKey:  s.apiKeyValue,
230					Type:    provider.Type,
231					BaseURL: provider.APIEndpoint,
232				}
233				return s, tea.Sequence(
234					util.CmdHandler(models.APIKeyStateChangeMsg{
235						State: models.APIKeyInputStateVerifying,
236					}),
237					func() tea.Msg {
238						start := time.Now()
239						err := providerConfig.TestConnection(config.Get().Resolver())
240						// intentionally wait for at least 750ms to make sure the user sees the spinner
241						elapsed := time.Since(start)
242						if elapsed < 750*time.Millisecond {
243							time.Sleep(750*time.Millisecond - elapsed)
244						}
245						if err == nil {
246							s.isAPIKeyValid = true
247							return models.APIKeyStateChangeMsg{
248								State: models.APIKeyInputStateVerified,
249							}
250						}
251						return models.APIKeyStateChangeMsg{
252							State: models.APIKeyInputStateError,
253						}
254					},
255				)
256			} else if s.needsProjectInit {
257				return s, s.initializeProject()
258			}
259		case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
260			if s.needsAPIKey {
261				u, cmd := s.apiKeyInput.Update(msg)
262				s.apiKeyInput = u.(*models.APIKeyInput)
263				return s, cmd
264			}
265			if s.needsProjectInit {
266				s.selectedNo = !s.selectedNo
267				return s, nil
268			}
269		case key.Matches(msg, s.keyMap.Yes):
270			if s.needsAPIKey {
271				u, cmd := s.apiKeyInput.Update(msg)
272				s.apiKeyInput = u.(*models.APIKeyInput)
273				return s, cmd
274			}
275
276			if s.needsProjectInit {
277				return s, s.initializeProject()
278			}
279		case key.Matches(msg, s.keyMap.No):
280			if s.needsAPIKey {
281				u, cmd := s.apiKeyInput.Update(msg)
282				s.apiKeyInput = u.(*models.APIKeyInput)
283				return s, cmd
284			}
285
286			s.selectedNo = true
287			return s, s.initializeProject()
288		default:
289			if s.needsAPIKey {
290				u, cmd := s.apiKeyInput.Update(msg)
291				s.apiKeyInput = u.(*models.APIKeyInput)
292				return s, cmd
293			} else if s.isOnboarding {
294				u, cmd := s.modelList.Update(msg)
295				s.modelList = u
296				return s, cmd
297			}
298		}
299	case tea.PasteMsg:
300		if s.needsAPIKey {
301			u, cmd := s.apiKeyInput.Update(msg)
302			s.apiKeyInput = u.(*models.APIKeyInput)
303			return s, cmd
304		} else if s.isOnboarding {
305			var cmd tea.Cmd
306			s.modelList, cmd = s.modelList.Update(msg)
307			return s, cmd
308		}
309	case spinner.TickMsg:
310		u, cmd := s.apiKeyInput.Update(msg)
311		s.apiKeyInput = u.(*models.APIKeyInput)
312		return s, cmd
313	}
314	return s, nil
315}
316
317func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
318	if s.selectedModel == nil {
319		return nil
320	}
321
322	cfg := config.Get()
323	err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
324	if err != nil {
325		return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
326	}
327
328	// Reset API key state and continue with model selection
329	s.needsAPIKey = false
330	cmd := s.setPreferredModel(*s.selectedModel)
331	s.isOnboarding = false
332	s.selectedModel = nil
333
334	return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
335}
336
337func (s *splashCmp) initializeProject() tea.Cmd {
338	s.needsProjectInit = false
339
340	if err := config.MarkProjectInitialized(); err != nil {
341		return util.ReportError(err)
342	}
343	var cmds []tea.Cmd
344
345	cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{}))
346	if !s.selectedNo {
347		cmds = append(cmds,
348			util.CmdHandler(chat.SessionClearedMsg{}),
349			util.CmdHandler(chat.SendMsg{
350				Text: prompt.Initialize(),
351			}),
352		)
353	}
354	return tea.Sequence(cmds...)
355}
356
357func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
358	cfg := config.Get()
359	model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
360	if model == nil {
361		return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
362	}
363
364	selectedModel := config.SelectedModel{
365		Model:           selectedItem.Model.ID,
366		Provider:        string(selectedItem.Provider.ID),
367		ReasoningEffort: model.DefaultReasoningEffort,
368		MaxTokens:       model.DefaultMaxTokens,
369	}
370
371	err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
372	if err != nil {
373		return util.ReportError(err)
374	}
375
376	// Now lets automatically setup the small model
377	knownProvider, err := s.getProvider(selectedItem.Provider.ID)
378	if err != nil {
379		return util.ReportError(err)
380	}
381	if knownProvider == nil {
382		// for local provider we just use the same model
383		err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
384		if err != nil {
385			return util.ReportError(err)
386		}
387	} else {
388		smallModel := knownProvider.DefaultSmallModelID
389		model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
390		// should never happen
391		if model == nil {
392			err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
393			if err != nil {
394				return util.ReportError(err)
395			}
396			return nil
397		}
398		smallSelectedModel := config.SelectedModel{
399			Model:           smallModel,
400			Provider:        string(selectedItem.Provider.ID),
401			ReasoningEffort: model.DefaultReasoningEffort,
402			MaxTokens:       model.DefaultMaxTokens,
403		}
404		err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
405		if err != nil {
406			return util.ReportError(err)
407		}
408	}
409	cfg.SetupAgents()
410	return nil
411}
412
413func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
414	providers, err := config.Providers()
415	if err != nil {
416		return nil, err
417	}
418	for _, p := range providers {
419		if p.ID == providerID {
420			return &p, nil
421		}
422	}
423	return nil, nil
424}
425
426func (s *splashCmp) isProviderConfigured(providerID string) bool {
427	cfg := config.Get()
428	if _, ok := cfg.Providers.Get(providerID); ok {
429		return true
430	}
431	return false
432}
433
434func (s *splashCmp) View() string {
435	t := styles.CurrentTheme()
436	var content string
437	if s.needsAPIKey {
438		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
439		apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
440		apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
441			lipgloss.JoinVertical(
442				lipgloss.Left,
443				apiKeyView,
444			),
445		)
446		content = lipgloss.JoinVertical(
447			lipgloss.Left,
448			s.logoRendered,
449			apiKeySelector,
450		)
451	} else if s.isOnboarding {
452		modelListView := s.modelList.View()
453		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
454		modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
455			lipgloss.JoinVertical(
456				lipgloss.Left,
457				t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Choose a Model"),
458				"",
459				modelListView,
460			),
461		)
462		content = lipgloss.JoinVertical(
463			lipgloss.Left,
464			s.logoRendered,
465			modelSelector,
466		)
467	} else if s.needsProjectInit {
468		titleStyle := t.S().Base.Foreground(t.FgBase)
469		bodyStyle := t.S().Base.Foreground(t.FgMuted)
470		shortcutStyle := t.S().Base.Foreground(t.Success)
471
472		initText := lipgloss.JoinVertical(
473			lipgloss.Left,
474			titleStyle.Render("Would you like to initialize this project?"),
475			"",
476			bodyStyle.Render("When I initialize your codebase I examine the project and put the"),
477			bodyStyle.Render("result into a CRUSH.md file which serves as general context."),
478			"",
479			bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."),
480			"",
481			bodyStyle.Render("Would you like to initialize now?"),
482		)
483
484		yesButton := core.SelectableButton(core.ButtonOpts{
485			Text:           "Yep!",
486			UnderlineIndex: 0,
487			Selected:       !s.selectedNo,
488		})
489
490		noButton := core.SelectableButton(core.ButtonOpts{
491			Text:           "Nope",
492			UnderlineIndex: 0,
493			Selected:       s.selectedNo,
494		})
495
496		buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, "  ", noButton)
497		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
498
499		initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render(
500			lipgloss.JoinVertical(
501				lipgloss.Left,
502				initText,
503				"",
504				buttons,
505			),
506		)
507
508		content = lipgloss.JoinVertical(
509			lipgloss.Left,
510			s.logoRendered,
511			"",
512			initContent,
513		)
514	} else {
515		parts := []string{
516			s.logoRendered,
517			s.infoSection(),
518		}
519		content = lipgloss.JoinVertical(lipgloss.Left, parts...)
520	}
521
522	return t.S().Base.
523		Width(s.width).
524		Height(s.height).
525		PaddingTop(SplashScreenPaddingY).
526		PaddingBottom(SplashScreenPaddingY).
527		Render(content)
528}
529
530func (s *splashCmp) Cursor() *tea.Cursor {
531	if s.needsAPIKey {
532		cursor := s.apiKeyInput.Cursor()
533		if cursor != nil {
534			return s.moveCursor(cursor)
535		}
536	} else if s.isOnboarding {
537		cursor := s.modelList.Cursor()
538		if cursor != nil {
539			return s.moveCursor(cursor)
540		}
541	} else {
542		return nil
543	}
544	return nil
545}
546
547func (s *splashCmp) isSmallScreen() bool {
548	// Consider a screen small if either the width is less than 40 or if the
549	// height is less than 20
550	return s.width < 55 || s.height < 20
551}
552
553func (s *splashCmp) infoSection() string {
554	t := styles.CurrentTheme()
555	infoStyle := t.S().Base.PaddingLeft(2)
556	if s.isSmallScreen() {
557		infoStyle = infoStyle.MarginTop(1)
558	}
559	return infoStyle.Render(
560		lipgloss.JoinVertical(
561			lipgloss.Left,
562			s.cwd(),
563			"",
564			lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
565			"",
566		),
567	)
568}
569
570func (s *splashCmp) logoBlock() string {
571	t := styles.CurrentTheme()
572	logoStyle := t.S().Base.Padding(0, 2).Width(s.width)
573	if s.isSmallScreen() {
574		// If the width is too small, render a smaller version of the logo
575		// NOTE: 20 is not correct because [splashCmp.height] is not the
576		// *actual* window height, instead, it is the height of the splash
577		// component and that depends on other variables like compact mode and
578		// the height of the editor.
579		return logoStyle.Render(
580			logo.SmallRender(s.width - logoStyle.GetHorizontalFrameSize()),
581		)
582	}
583	return logoStyle.Render(
584		logo.Render(version.Version, false, logo.Opts{
585			FieldColor:   t.Primary,
586			TitleColorA:  t.Secondary,
587			TitleColorB:  t.Primary,
588			CharmColor:   t.Secondary,
589			VersionColor: t.Primary,
590			Width:        s.width - logoStyle.GetHorizontalFrameSize(),
591		}),
592	)
593}
594
595func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
596	if cursor == nil {
597		return nil
598	}
599	// Calculate the correct Y offset based on current state
600	logoHeight := lipgloss.Height(s.logoRendered)
601	if s.needsAPIKey {
602		infoSectionHeight := lipgloss.Height(s.infoSection())
603		baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
604		remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
605		offset := baseOffset + remainingHeight
606		cursor.Y += offset
607		cursor.X = cursor.X + 1
608	} else if s.isOnboarding {
609		offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
610		cursor.Y += offset
611		cursor.X = cursor.X + 1
612	}
613
614	return cursor
615}
616
617func (s *splashCmp) logoGap() int {
618	if s.height > 35 {
619		return LogoGap
620	}
621	return 0
622}
623
624// Bindings implements SplashPage.
625func (s *splashCmp) Bindings() []key.Binding {
626	if s.needsAPIKey {
627		return []key.Binding{
628			s.keyMap.Select,
629			s.keyMap.Back,
630		}
631	} else if s.isOnboarding {
632		return []key.Binding{
633			s.keyMap.Select,
634			s.keyMap.Next,
635			s.keyMap.Previous,
636		}
637	} else if s.needsProjectInit {
638		return []key.Binding{
639			s.keyMap.Select,
640			s.keyMap.Yes,
641			s.keyMap.No,
642			s.keyMap.Tab,
643			s.keyMap.LeftRight,
644		}
645	}
646	return []key.Binding{}
647}
648
649func (s *splashCmp) getMaxInfoWidth() int {
650	return min(s.width-2, 40) // 2 for left padding
651}
652
653func (s *splashCmp) cwd() string {
654	cwd := config.Get().WorkingDir()
655	t := styles.CurrentTheme()
656	homeDir, err := os.UserHomeDir()
657	if err == nil && cwd != homeDir {
658		cwd = strings.ReplaceAll(cwd, homeDir, "~")
659	}
660	maxWidth := s.getMaxInfoWidth()
661	return t.S().Muted.Width(maxWidth).Render(cwd)
662}
663
664func LSPList(maxWidth int) []string {
665	t := styles.CurrentTheme()
666	lspList := []string{}
667	lsp := config.Get().LSP.Sorted()
668	if len(lsp) == 0 {
669		return []string{t.S().Base.Foreground(t.Border).Render("None")}
670	}
671	for _, l := range lsp {
672		iconColor := t.Success
673		if l.LSP.Disabled {
674			iconColor = t.FgMuted
675		}
676		lspList = append(lspList,
677			core.Status(
678				core.StatusOpts{
679					IconColor:   iconColor,
680					Title:       l.Name,
681					Description: l.LSP.Command,
682				},
683				maxWidth,
684			),
685		)
686	}
687	return lspList
688}
689
690func (s *splashCmp) lspBlock() string {
691	t := styles.CurrentTheme()
692	maxWidth := s.getMaxInfoWidth() / 2
693	section := t.S().Subtle.Render("LSPs")
694	lspList := append([]string{section, ""}, LSPList(maxWidth-1)...)
695	return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
696		lipgloss.JoinVertical(
697			lipgloss.Left,
698			lspList...,
699		),
700	)
701}
702
703func MCPList(maxWidth int) []string {
704	t := styles.CurrentTheme()
705	mcpList := []string{}
706	mcps := config.Get().MCP.Sorted()
707	if len(mcps) == 0 {
708		return []string{t.S().Base.Foreground(t.Border).Render("None")}
709	}
710	for _, l := range mcps {
711		iconColor := t.Success
712		if l.MCP.Disabled {
713			iconColor = t.FgMuted
714		}
715		mcpList = append(mcpList,
716			core.Status(
717				core.StatusOpts{
718					IconColor:   iconColor,
719					Title:       l.Name,
720					Description: l.MCP.Command,
721				},
722				maxWidth,
723			),
724		)
725	}
726	return mcpList
727}
728
729func (s *splashCmp) mcpBlock() string {
730	t := styles.CurrentTheme()
731	maxWidth := s.getMaxInfoWidth() / 2
732	section := t.S().Subtle.Render("MCPs")
733	mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...)
734	return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
735		lipgloss.JoinVertical(
736			lipgloss.Left,
737			mcpList...,
738		),
739	)
740}
741
742func (s *splashCmp) IsShowingAPIKey() bool {
743	return s.needsAPIKey
744}
745
746func (s *splashCmp) IsAPIKeyValid() bool {
747	return s.isAPIKeyValid
748}