splash.go

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