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}