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