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