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