splash.go

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