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