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