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}