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