1package splash
2
3import (
4 "fmt"
5 "os"
6 "slices"
7 "strings"
8
9 "github.com/charmbracelet/bubbles/v2/key"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/fur/provider"
13 "github.com/charmbracelet/crush/internal/llm/prompt"
14 "github.com/charmbracelet/crush/internal/tui/components/chat"
15 "github.com/charmbracelet/crush/internal/tui/components/completions"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18 "github.com/charmbracelet/crush/internal/tui/components/core/list"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
20 "github.com/charmbracelet/crush/internal/tui/components/logo"
21 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/util"
23 "github.com/charmbracelet/crush/internal/version"
24 "github.com/charmbracelet/lipgloss/v2"
25)
26
27type Splash interface {
28 util.Model
29 layout.Sizeable
30 layout.Help
31 Cursor() *tea.Cursor
32 // SetOnboarding controls whether the splash shows model selection UI
33 SetOnboarding(bool)
34 // SetProjectInit controls whether the splash shows project initialization prompt
35 SetProjectInit(bool)
36
37 // Showing API key input
38 IsShowingAPIKey() bool
39}
40
41const (
42 SplashScreenPaddingY = 1 // Padding Y for the splash screen
43
44 LogoGap = 6
45)
46
47// OnboardingCompleteMsg is sent when onboarding is complete
48type OnboardingCompleteMsg struct{}
49
50type splashCmp struct {
51 width, height int
52 keyMap KeyMap
53 logoRendered string
54
55 // State
56 isOnboarding bool
57 needsProjectInit bool
58 needsAPIKey bool
59 selectedNo bool
60
61 listHeight int
62 modelList *models.ModelListComponent
63 apiKeyInput *models.APIKeyInput
64 selectedModel *models.ModelOption
65}
66
67func New() Splash {
68 keyMap := DefaultKeyMap()
69 listKeyMap := list.DefaultKeyMap()
70 listKeyMap.Down.SetEnabled(false)
71 listKeyMap.Up.SetEnabled(false)
72 listKeyMap.HalfPageDown.SetEnabled(false)
73 listKeyMap.HalfPageUp.SetEnabled(false)
74 listKeyMap.Home.SetEnabled(false)
75 listKeyMap.End.SetEnabled(false)
76 listKeyMap.DownOneItem = keyMap.Next
77 listKeyMap.UpOneItem = keyMap.Previous
78
79 t := styles.CurrentTheme()
80 inputStyle := t.S().Base.Padding(0, 1, 0, 1)
81 modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave")
82 apiKeyInput := models.NewAPIKeyInput()
83
84 return &splashCmp{
85 width: 0,
86 height: 0,
87 keyMap: keyMap,
88 logoRendered: "",
89 modelList: modelList,
90 apiKeyInput: apiKeyInput,
91 selectedNo: false,
92 }
93}
94
95func (s *splashCmp) SetOnboarding(onboarding bool) {
96 s.isOnboarding = onboarding
97 if onboarding {
98 providers, err := config.Providers()
99 if err != nil {
100 return
101 }
102 filteredProviders := []provider.Provider{}
103 simpleProviders := []string{
104 "anthropic",
105 "openai",
106 "gemini",
107 "xai",
108 "groq",
109 "openrouter",
110 }
111 for _, p := range providers {
112 if slices.Contains(simpleProviders, string(p.ID)) {
113 filteredProviders = append(filteredProviders, p)
114 }
115 }
116 s.modelList.SetProviders(filteredProviders)
117 }
118}
119
120func (s *splashCmp) SetProjectInit(needsInit bool) {
121 s.needsProjectInit = needsInit
122}
123
124// GetSize implements SplashPage.
125func (s *splashCmp) GetSize() (int, int) {
126 return s.width, s.height
127}
128
129// Init implements SplashPage.
130func (s *splashCmp) Init() tea.Cmd {
131 return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init())
132}
133
134// SetSize implements SplashPage.
135func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
136 rerenderLogo := width != s.width
137 s.height = height
138 s.width = width
139 if rerenderLogo {
140 s.logoRendered = s.logoBlock()
141 }
142 // remove padding, logo height, gap, title space
143 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
144 listWidth := min(60, width)
145 return s.modelList.SetSize(listWidth, s.listHeight)
146}
147
148// Update implements SplashPage.
149func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
150 switch msg := msg.(type) {
151 case tea.WindowSizeMsg:
152 return s, s.SetSize(msg.Width, msg.Height)
153 case tea.KeyPressMsg:
154 switch {
155 case key.Matches(msg, s.keyMap.Back):
156 if s.needsAPIKey {
157 // Go back to model selection
158 s.needsAPIKey = false
159 s.selectedModel = nil
160 return s, nil
161 }
162 case key.Matches(msg, s.keyMap.Select):
163 if s.isOnboarding && !s.needsAPIKey {
164 modelInx := s.modelList.SelectedIndex()
165 items := s.modelList.Items()
166 selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
167 if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
168 cmd := s.setPreferredModel(selectedItem)
169 s.isOnboarding = false
170 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
171 } else {
172 // Provider not configured, show API key input
173 s.needsAPIKey = true
174 s.selectedModel = &selectedItem
175 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
176 return s, nil
177 }
178 } else if s.needsAPIKey {
179 // Handle API key submission
180 apiKey := s.apiKeyInput.Value()
181 if apiKey != "" {
182 return s, s.saveAPIKeyAndContinue(apiKey)
183 }
184 } else if s.needsProjectInit {
185 return s, s.initializeProject()
186 }
187 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
188 if s.needsProjectInit {
189 s.selectedNo = !s.selectedNo
190 return s, nil
191 }
192 case key.Matches(msg, s.keyMap.Yes):
193 if s.needsProjectInit {
194 return s, s.initializeProject()
195 }
196 case key.Matches(msg, s.keyMap.No):
197 s.selectedNo = true
198 return s, s.initializeProject()
199 default:
200 if s.needsAPIKey {
201 u, cmd := s.apiKeyInput.Update(msg)
202 s.apiKeyInput = u.(*models.APIKeyInput)
203 return s, cmd
204 } else if s.isOnboarding {
205 u, cmd := s.modelList.Update(msg)
206 s.modelList = u
207 return s, cmd
208 }
209 }
210 case tea.PasteMsg:
211 if s.needsAPIKey {
212 u, cmd := s.apiKeyInput.Update(msg)
213 s.apiKeyInput = u.(*models.APIKeyInput)
214 return s, cmd
215 } else if s.isOnboarding {
216 var cmd tea.Cmd
217 s.modelList, cmd = s.modelList.Update(msg)
218 return s, cmd
219 }
220 }
221 return s, nil
222}
223
224func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
225 if s.selectedModel == nil {
226 return util.ReportError(fmt.Errorf("no model selected"))
227 }
228
229 cfg := config.Get()
230 err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
231 if err != nil {
232 return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
233 }
234
235 // Reset API key state and continue with model selection
236 s.needsAPIKey = false
237 cmd := s.setPreferredModel(*s.selectedModel)
238 s.isOnboarding = false
239 s.selectedModel = nil
240
241 return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
242}
243
244func (s *splashCmp) initializeProject() tea.Cmd {
245 s.needsProjectInit = false
246
247 if err := config.MarkProjectInitialized(); err != nil {
248 return util.ReportError(err)
249 }
250 var cmds []tea.Cmd
251
252 cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{}))
253 if !s.selectedNo {
254 cmds = append(cmds,
255 util.CmdHandler(chat.SessionClearedMsg{}),
256 util.CmdHandler(chat.SendMsg{
257 Text: prompt.Initialize(),
258 }),
259 )
260 }
261 return tea.Sequence(cmds...)
262}
263
264func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
265 cfg := config.Get()
266 model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
267 if model == nil {
268 return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
269 }
270
271 selectedModel := config.SelectedModel{
272 Model: selectedItem.Model.ID,
273 Provider: string(selectedItem.Provider.ID),
274 ReasoningEffort: model.DefaultReasoningEffort,
275 MaxTokens: model.DefaultMaxTokens,
276 }
277
278 err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
279 if err != nil {
280 return util.ReportError(err)
281 }
282
283 // Now lets automatically setup the small model
284 knownProvider, err := s.getProvider(selectedItem.Provider.ID)
285 if err != nil {
286 return util.ReportError(err)
287 }
288 if knownProvider == nil {
289 // for local provider we just use the same model
290 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
291 if err != nil {
292 return util.ReportError(err)
293 }
294 } else {
295 smallModel := knownProvider.DefaultSmallModelID
296 model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
297 // should never happen
298 if model == nil {
299 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
300 if err != nil {
301 return util.ReportError(err)
302 }
303 return nil
304 }
305 smallSelectedModel := config.SelectedModel{
306 Model: smallModel,
307 Provider: string(selectedItem.Provider.ID),
308 ReasoningEffort: model.DefaultReasoningEffort,
309 MaxTokens: model.DefaultMaxTokens,
310 }
311 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
312 if err != nil {
313 return util.ReportError(err)
314 }
315 }
316 cfg.SetupAgents()
317 return nil
318}
319
320func (s *splashCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
321 providers, err := config.Providers()
322 if err != nil {
323 return nil, err
324 }
325 for _, p := range providers {
326 if p.ID == providerID {
327 return &p, nil
328 }
329 }
330 return nil, nil
331}
332
333func (s *splashCmp) isProviderConfigured(providerID string) bool {
334 cfg := config.Get()
335 if _, ok := cfg.Providers[providerID]; ok {
336 return true
337 }
338 return false
339}
340
341func (s *splashCmp) View() string {
342 t := styles.CurrentTheme()
343 var content string
344 if s.needsAPIKey {
345 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
346 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
347 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
348 lipgloss.JoinVertical(
349 lipgloss.Left,
350 apiKeyView,
351 ),
352 )
353 content = lipgloss.JoinVertical(
354 lipgloss.Left,
355 s.logoRendered,
356 apiKeySelector,
357 )
358 } else if s.isOnboarding {
359 modelListView := s.modelList.View()
360 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
361 modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
362 lipgloss.JoinVertical(
363 lipgloss.Left,
364 t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Choose a Model"),
365 "",
366 modelListView,
367 ),
368 )
369 content = lipgloss.JoinVertical(
370 lipgloss.Left,
371 s.logoRendered,
372 modelSelector,
373 )
374 } else if s.needsProjectInit {
375 titleStyle := t.S().Base.Foreground(t.FgBase)
376 bodyStyle := t.S().Base.Foreground(t.FgMuted)
377 shortcutStyle := t.S().Base.Foreground(t.Success)
378
379 initText := lipgloss.JoinVertical(
380 lipgloss.Left,
381 titleStyle.Render("Would you like to initialize this project?"),
382 "",
383 bodyStyle.Render("When I initialize your codebase I examine the project and put the"),
384 bodyStyle.Render("result into a CRUSH.md file which serves as general context."),
385 "",
386 bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."),
387 "",
388 bodyStyle.Render("Would you like to initialize now?"),
389 )
390
391 yesButton := core.SelectableButton(core.ButtonOpts{
392 Text: "Yep!",
393 UnderlineIndex: 0,
394 Selected: !s.selectedNo,
395 })
396
397 noButton := core.SelectableButton(core.ButtonOpts{
398 Text: "Nope",
399 UnderlineIndex: 0,
400 Selected: s.selectedNo,
401 })
402
403 buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton)
404 infoSection := s.infoSection()
405
406 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - lipgloss.Height(infoSection)
407
408 initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render(
409 lipgloss.JoinVertical(
410 lipgloss.Left,
411 initText,
412 "",
413 buttons,
414 ),
415 )
416
417 content = lipgloss.JoinVertical(
418 lipgloss.Left,
419 s.logoRendered,
420 infoSection,
421 initContent,
422 )
423 } else {
424 parts := []string{
425 s.logoRendered,
426 s.infoSection(),
427 }
428 content = lipgloss.JoinVertical(lipgloss.Left, parts...)
429 }
430
431 return t.S().Base.
432 Width(s.width).
433 Height(s.height).
434 PaddingTop(SplashScreenPaddingY).
435 PaddingBottom(SplashScreenPaddingY).
436 Render(content)
437}
438
439func (s *splashCmp) Cursor() *tea.Cursor {
440 if s.needsAPIKey {
441 cursor := s.apiKeyInput.Cursor()
442 if cursor != nil {
443 return s.moveCursor(cursor)
444 }
445 } else if s.isOnboarding {
446 cursor := s.modelList.Cursor()
447 if cursor != nil {
448 return s.moveCursor(cursor)
449 }
450 } else {
451 return nil
452 }
453 return nil
454}
455
456func (s *splashCmp) isSmallScreen() bool {
457 // Consider a screen small if either the width is less than 40 or if the
458 // height is less than 20
459 return s.width < 40 || s.height < 20
460}
461
462func (s *splashCmp) infoSection() string {
463 t := styles.CurrentTheme()
464 infoStyle := t.S().Base.PaddingLeft(2)
465 if s.isSmallScreen() {
466 infoStyle = infoStyle.MarginTop(1)
467 }
468 return infoStyle.Render(
469 lipgloss.JoinVertical(
470 lipgloss.Left,
471 s.cwd(),
472 "",
473 lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
474 "",
475 ),
476 )
477}
478
479func (s *splashCmp) logoBlock() string {
480 t := styles.CurrentTheme()
481 logoStyle := t.S().Base.Padding(0, 2).Width(s.width)
482 if s.isSmallScreen() {
483 // If the width is too small, render a smaller version of the logo
484 // NOTE: 20 is not correct because [splashCmp.height] is not the
485 // *actual* window height, instead, it is the height of the splash
486 // component and that depends on other variables like compact mode and
487 // the height of the editor.
488 return logoStyle.Render(
489 logo.SmallRender(s.width - logoStyle.GetHorizontalFrameSize()),
490 )
491 }
492 return logoStyle.Render(
493 logo.Render(version.Version, false, logo.Opts{
494 FieldColor: t.Primary,
495 TitleColorA: t.Secondary,
496 TitleColorB: t.Primary,
497 CharmColor: t.Secondary,
498 VersionColor: t.Primary,
499 Width: s.width - logoStyle.GetHorizontalFrameSize(),
500 }),
501 )
502}
503
504func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
505 if cursor == nil {
506 return nil
507 }
508 // Calculate the correct Y offset based on current state
509 logoHeight := lipgloss.Height(s.logoRendered)
510 if s.needsAPIKey {
511 infoSectionHeight := lipgloss.Height(s.infoSection())
512 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
513 remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
514 offset := baseOffset + remainingHeight
515 cursor.Y += offset
516 cursor.X = cursor.X + 1
517 } else if s.isOnboarding {
518 offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
519 cursor.Y += offset
520 cursor.X = cursor.X + 1
521 }
522
523 return cursor
524}
525
526func (s *splashCmp) logoGap() int {
527 if s.height > 35 {
528 return LogoGap
529 }
530 return 0
531}
532
533// Bindings implements SplashPage.
534func (s *splashCmp) Bindings() []key.Binding {
535 if s.needsAPIKey {
536 return []key.Binding{
537 s.keyMap.Select,
538 s.keyMap.Back,
539 }
540 } else if s.isOnboarding {
541 return []key.Binding{
542 s.keyMap.Select,
543 s.keyMap.Next,
544 s.keyMap.Previous,
545 }
546 } else if s.needsProjectInit {
547 return []key.Binding{
548 s.keyMap.Select,
549 s.keyMap.Yes,
550 s.keyMap.No,
551 s.keyMap.Tab,
552 s.keyMap.LeftRight,
553 }
554 }
555 return []key.Binding{}
556}
557
558func (s *splashCmp) getMaxInfoWidth() int {
559 return min(s.width-2, 40) // 2 for left padding
560}
561
562func (s *splashCmp) cwd() string {
563 cwd := config.Get().WorkingDir()
564 t := styles.CurrentTheme()
565 homeDir, err := os.UserHomeDir()
566 if err == nil && cwd != homeDir {
567 cwd = strings.ReplaceAll(cwd, homeDir, "~")
568 }
569 maxWidth := s.getMaxInfoWidth()
570 return t.S().Muted.Width(maxWidth).Render(cwd)
571}
572
573func LSPList(maxWidth int) []string {
574 t := styles.CurrentTheme()
575 lspList := []string{}
576 lsp := config.Get().LSP.Sorted()
577 if len(lsp) == 0 {
578 return []string{t.S().Base.Foreground(t.Border).Render("None")}
579 }
580 for _, l := range lsp {
581 iconColor := t.Success
582 if l.LSP.Disabled {
583 iconColor = t.FgMuted
584 }
585 lspList = append(lspList,
586 core.Status(
587 core.StatusOpts{
588 IconColor: iconColor,
589 Title: l.Name,
590 Description: l.LSP.Command,
591 },
592 maxWidth,
593 ),
594 )
595 }
596 return lspList
597}
598
599func (s *splashCmp) lspBlock() string {
600 t := styles.CurrentTheme()
601 maxWidth := s.getMaxInfoWidth() / 2
602 section := t.S().Subtle.Render("LSPs")
603 lspList := append([]string{section, ""}, LSPList(maxWidth-1)...)
604 return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
605 lipgloss.JoinVertical(
606 lipgloss.Left,
607 lspList...,
608 ),
609 )
610}
611
612func MCPList(maxWidth int) []string {
613 t := styles.CurrentTheme()
614 mcpList := []string{}
615 mcps := config.Get().MCP.Sorted()
616 if len(mcps) == 0 {
617 return []string{t.S().Base.Foreground(t.Border).Render("None")}
618 }
619 for _, l := range mcps {
620 iconColor := t.Success
621 if l.MCP.Disabled {
622 iconColor = t.FgMuted
623 }
624 mcpList = append(mcpList,
625 core.Status(
626 core.StatusOpts{
627 IconColor: iconColor,
628 Title: l.Name,
629 Description: l.MCP.Command,
630 },
631 maxWidth,
632 ),
633 )
634 }
635 return mcpList
636}
637
638func (s *splashCmp) mcpBlock() string {
639 t := styles.CurrentTheme()
640 maxWidth := s.getMaxInfoWidth() / 2
641 section := t.S().Subtle.Render("MCPs")
642 mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...)
643 return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
644 lipgloss.JoinVertical(
645 lipgloss.Left,
646 mcpList...,
647 ),
648 )
649}
650
651func (s *splashCmp) IsShowingAPIKey() bool {
652 return s.needsAPIKey
653}