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) infoSection() string {
457 t := styles.CurrentTheme()
458 return t.S().Base.PaddingLeft(2).Render(
459 lipgloss.JoinVertical(
460 lipgloss.Left,
461 s.cwd(),
462 "",
463 lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
464 "",
465 ),
466 )
467}
468
469func (s *splashCmp) logoBlock() string {
470 t := styles.CurrentTheme()
471 logoStyle := t.S().Base.Padding(0, 2).Width(s.width)
472 if s.width < 40 || s.height < 20 {
473 // If the width is too small, render a smaller version of the logo
474 // NOTE: 20 is not correct because [splashCmp.height] is not the
475 // *actual* window height, instead, it is the height of the splash
476 // component and that depends on other variables like compact mode and
477 // the height of the editor.
478 return logoStyle.Render(
479 logo.SmallRender(s.width - logoStyle.GetHorizontalFrameSize()),
480 )
481 }
482 return logoStyle.Render(
483 logo.Render(version.Version, false, logo.Opts{
484 FieldColor: t.Primary,
485 TitleColorA: t.Secondary,
486 TitleColorB: t.Primary,
487 CharmColor: t.Secondary,
488 VersionColor: t.Primary,
489 Width: s.width - logoStyle.GetHorizontalFrameSize(),
490 }),
491 )
492}
493
494func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
495 if cursor == nil {
496 return nil
497 }
498 // Calculate the correct Y offset based on current state
499 logoHeight := lipgloss.Height(s.logoRendered)
500 if s.needsAPIKey {
501 infoSectionHeight := lipgloss.Height(s.infoSection())
502 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
503 remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
504 offset := baseOffset + remainingHeight
505 cursor.Y += offset
506 cursor.X = cursor.X + 1
507 } else if s.isOnboarding {
508 offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
509 cursor.Y += offset
510 cursor.X = cursor.X + 1
511 }
512
513 return cursor
514}
515
516func (s *splashCmp) logoGap() int {
517 if s.height > 35 {
518 return LogoGap
519 }
520 return 0
521}
522
523// Bindings implements SplashPage.
524func (s *splashCmp) Bindings() []key.Binding {
525 if s.needsAPIKey {
526 return []key.Binding{
527 s.keyMap.Select,
528 s.keyMap.Back,
529 }
530 } else if s.isOnboarding {
531 return []key.Binding{
532 s.keyMap.Select,
533 s.keyMap.Next,
534 s.keyMap.Previous,
535 }
536 } else if s.needsProjectInit {
537 return []key.Binding{
538 s.keyMap.Select,
539 s.keyMap.Yes,
540 s.keyMap.No,
541 s.keyMap.Tab,
542 s.keyMap.LeftRight,
543 }
544 }
545 return []key.Binding{}
546}
547
548func (s *splashCmp) getMaxInfoWidth() int {
549 return min(s.width-2, 40) // 2 for left padding
550}
551
552func (s *splashCmp) cwd() string {
553 cwd := config.Get().WorkingDir()
554 t := styles.CurrentTheme()
555 homeDir, err := os.UserHomeDir()
556 if err == nil && cwd != homeDir {
557 cwd = strings.ReplaceAll(cwd, homeDir, "~")
558 }
559 maxWidth := s.getMaxInfoWidth()
560 return t.S().Muted.Width(maxWidth).Render(cwd)
561}
562
563func LSPList(maxWidth int) []string {
564 t := styles.CurrentTheme()
565 lspList := []string{}
566 lsp := config.Get().LSP.Sorted()
567 if len(lsp) == 0 {
568 return []string{t.S().Base.Foreground(t.Border).Render("None")}
569 }
570 for _, l := range lsp {
571 iconColor := t.Success
572 if l.LSP.Disabled {
573 iconColor = t.FgMuted
574 }
575 lspList = append(lspList,
576 core.Status(
577 core.StatusOpts{
578 IconColor: iconColor,
579 Title: l.Name,
580 Description: l.LSP.Command,
581 },
582 maxWidth,
583 ),
584 )
585 }
586 return lspList
587}
588
589func (s *splashCmp) lspBlock() string {
590 t := styles.CurrentTheme()
591 maxWidth := s.getMaxInfoWidth() / 2
592 section := t.S().Subtle.Render("LSPs")
593 lspList := append([]string{section, ""}, LSPList(maxWidth-1)...)
594 return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
595 lipgloss.JoinVertical(
596 lipgloss.Left,
597 lspList...,
598 ),
599 )
600}
601
602func MCPList(maxWidth int) []string {
603 t := styles.CurrentTheme()
604 mcpList := []string{}
605 mcps := config.Get().MCP.Sorted()
606 if len(mcps) == 0 {
607 return []string{t.S().Base.Foreground(t.Border).Render("None")}
608 }
609 for _, l := range mcps {
610 iconColor := t.Success
611 if l.MCP.Disabled {
612 iconColor = t.FgMuted
613 }
614 mcpList = append(mcpList,
615 core.Status(
616 core.StatusOpts{
617 IconColor: iconColor,
618 Title: l.Name,
619 Description: l.MCP.Command,
620 },
621 maxWidth,
622 ),
623 )
624 }
625 return mcpList
626}
627
628func (s *splashCmp) mcpBlock() string {
629 t := styles.CurrentTheme()
630 maxWidth := s.getMaxInfoWidth() / 2
631 section := t.S().Subtle.Render("MCPs")
632 mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...)
633 return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
634 lipgloss.JoinVertical(
635 lipgloss.Left,
636 mcpList...,
637 ),
638 )
639}
640
641func (s *splashCmp) IsShowingAPIKey() bool {
642 return s.needsAPIKey
643}