splash.go

  1package splash
  2
  3import (
  4	"fmt"
  5	"slices"
  6
  7	"github.com/charmbracelet/bubbles/v2/key"
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/crush/internal/config"
 10	"github.com/charmbracelet/crush/internal/fur/provider"
 11	"github.com/charmbracelet/crush/internal/tui/components/chat"
 12	"github.com/charmbracelet/crush/internal/tui/components/completions"
 13	"github.com/charmbracelet/crush/internal/tui/components/core"
 14	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 15	"github.com/charmbracelet/crush/internal/tui/components/core/list"
 16	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 17	"github.com/charmbracelet/crush/internal/tui/components/logo"
 18	"github.com/charmbracelet/crush/internal/tui/styles"
 19	"github.com/charmbracelet/crush/internal/tui/util"
 20	"github.com/charmbracelet/crush/internal/version"
 21	"github.com/charmbracelet/lipgloss/v2"
 22)
 23
 24type Splash interface {
 25	util.Model
 26	layout.Sizeable
 27	layout.Help
 28}
 29
 30const (
 31	SplashScreenPaddingX = 2 // Padding X for the splash screen
 32	SplashScreenPaddingY = 1 // Padding Y for the splash screen
 33)
 34
 35type SplashScreenState string
 36
 37const (
 38	SplashScreenStateOnboarding SplashScreenState = "onboarding"
 39	SplashScreenStateInitialize SplashScreenState = "initialize"
 40	SplashScreenStateReady      SplashScreenState = "ready"
 41)
 42
 43// OnboardingCompleteMsg is sent when onboarding is complete
 44type OnboardingCompleteMsg struct{}
 45
 46type splashCmp struct {
 47	width, height        int
 48	keyMap               KeyMap
 49	logoRendered         string
 50	state                SplashScreenState
 51	modelList            *models.ModelListComponent
 52	cursorRow, cursorCol int
 53	selectedNo           bool // true if "No" button is selected in initialize state
 54}
 55
 56func New() Splash {
 57	keyMap := DefaultKeyMap()
 58	listKeyMap := list.DefaultKeyMap()
 59	listKeyMap.Down.SetEnabled(false)
 60	listKeyMap.Up.SetEnabled(false)
 61	listKeyMap.HalfPageDown.SetEnabled(false)
 62	listKeyMap.HalfPageUp.SetEnabled(false)
 63	listKeyMap.Home.SetEnabled(false)
 64	listKeyMap.End.SetEnabled(false)
 65	listKeyMap.DownOneItem = keyMap.Next
 66	listKeyMap.UpOneItem = keyMap.Previous
 67
 68	t := styles.CurrentTheme()
 69	inputStyle := t.S().Base.Padding(0, 1, 0, 1)
 70	modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave")
 71	return &splashCmp{
 72		width:        0,
 73		height:       0,
 74		keyMap:       keyMap,
 75		state:        SplashScreenStateOnboarding,
 76		logoRendered: "",
 77		modelList:    modelList,
 78		selectedNo:   false,
 79	}
 80}
 81
 82// GetSize implements SplashPage.
 83func (s *splashCmp) GetSize() (int, int) {
 84	return s.width, s.height
 85}
 86
 87// Init implements SplashPage.
 88func (s *splashCmp) Init() tea.Cmd {
 89	if config.HasInitialDataConfig() {
 90		if b, _ := config.ProjectNeedsInitialization(); b {
 91			s.state = SplashScreenStateInitialize
 92		} else {
 93			s.state = SplashScreenStateReady
 94		}
 95	} else {
 96		providers, err := config.Providers()
 97		if err != nil {
 98			return util.ReportError(err)
 99		}
100		filteredProviders := []provider.Provider{}
101		simpleProviders := []string{
102			"anthropic",
103			"openai",
104			"gemini",
105			"xai",
106			"openrouter",
107		}
108		for _, p := range providers {
109			if slices.Contains(simpleProviders, string(p.ID)) {
110				filteredProviders = append(filteredProviders, p)
111			}
112		}
113		s.modelList.SetProviders(filteredProviders)
114		return s.modelList.Init()
115	}
116	return nil
117}
118
119// SetSize implements SplashPage.
120func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
121	s.width = width
122	s.height = height
123	s.logoRendered = s.logoBlock()
124	listHeigh := min(40, height-(SplashScreenPaddingY*2)-lipgloss.Height(s.logoRendered)-2) // -1 for the title
125	listWidth := min(60, width-(SplashScreenPaddingX*2))
126
127	// Calculate the cursor position based on the height and logo size
128	s.cursorRow = height - listHeigh
129	return s.modelList.SetSize(listWidth, listHeigh)
130}
131
132// Update implements SplashPage.
133func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
134	switch msg := msg.(type) {
135	case tea.WindowSizeMsg:
136		return s, s.SetSize(msg.Width, msg.Height)
137	case tea.KeyPressMsg:
138		switch {
139		case key.Matches(msg, s.keyMap.Select):
140			if s.state == SplashScreenStateOnboarding {
141				modelInx := s.modelList.SelectedIndex()
142				items := s.modelList.Items()
143				selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
144				if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
145					cmd := s.setPreferredModel(selectedItem)
146					s.state = SplashScreenStateReady
147					if b, err := config.ProjectNeedsInitialization(); err != nil {
148						return s, tea.Batch(cmd, util.ReportError(err))
149					} else if b {
150						s.state = SplashScreenStateInitialize
151						return s, cmd
152					} else {
153						s.state = SplashScreenStateReady
154						return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
155					}
156				}
157			} else if s.state == SplashScreenStateInitialize {
158				return s, s.initializeProject()
159			}
160		case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
161			if s.state == SplashScreenStateInitialize {
162				s.selectedNo = !s.selectedNo
163				return s, nil
164			}
165		case key.Matches(msg, s.keyMap.Yes):
166			if s.state == SplashScreenStateInitialize {
167				return s, s.initializeProject()
168			}
169		case key.Matches(msg, s.keyMap.No):
170			if s.state == SplashScreenStateInitialize {
171				s.state = SplashScreenStateReady
172				return s, util.CmdHandler(OnboardingCompleteMsg{})
173			}
174		default:
175			if s.state == SplashScreenStateOnboarding {
176				u, cmd := s.modelList.Update(msg)
177				s.modelList = u
178				return s, cmd
179			}
180		}
181	}
182	return s, nil
183}
184
185func (s *splashCmp) initializeProject() tea.Cmd {
186	s.state = SplashScreenStateReady
187	prompt := `Please analyze this codebase and create a CRUSH.md file containing:
1881. Build/lint/test commands - especially for running a single test
1892. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
190
191The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
192If there's already a CRUSH.md, improve it.
193If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
194Add the .crush directory to the .gitignore file if it's not already there.`
195
196	// Mark the project as initialized
197	if err := config.MarkProjectInitialized(); err != nil {
198		return util.ReportError(err)
199	}
200	var cmds []tea.Cmd
201
202	cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{}))
203	if !s.selectedNo {
204		cmds = append(cmds,
205			util.CmdHandler(chat.SessionClearedMsg{}),
206			util.CmdHandler(chat.SendMsg{
207				Text: prompt,
208			}),
209		)
210	}
211	return tea.Sequence(cmds...)
212}
213
214func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
215	cfg := config.Get()
216	model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
217	if model == nil {
218		return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
219	}
220
221	selectedModel := config.SelectedModel{
222		Model:           selectedItem.Model.ID,
223		Provider:        string(selectedItem.Provider.ID),
224		ReasoningEffort: model.DefaultReasoningEffort,
225		MaxTokens:       model.DefaultMaxTokens,
226	}
227
228	err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
229	if err != nil {
230		return util.ReportError(err)
231	}
232
233	// Now lets automatically setup the small model
234	knownProvider, err := s.getProvider(selectedItem.Provider.ID)
235	if err != nil {
236		return util.ReportError(err)
237	}
238	if knownProvider == nil {
239		// for local provider we just use the same model
240		err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
241		if err != nil {
242			return util.ReportError(err)
243		}
244	} else {
245		smallModel := knownProvider.DefaultSmallModelID
246		model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
247		// should never happen
248		if model == nil {
249			err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
250			if err != nil {
251				return util.ReportError(err)
252			}
253			return nil
254		}
255		smallSelectedModel := config.SelectedModel{
256			Model:           smallModel,
257			Provider:        string(selectedItem.Provider.ID),
258			ReasoningEffort: model.DefaultReasoningEffort,
259			MaxTokens:       model.DefaultMaxTokens,
260		}
261		err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
262		if err != nil {
263			return util.ReportError(err)
264		}
265	}
266	return nil
267}
268
269func (s *splashCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
270	providers, err := config.Providers()
271	if err != nil {
272		return nil, err
273	}
274	for _, p := range providers {
275		if p.ID == providerID {
276			return &p, nil
277		}
278	}
279	return nil, nil
280}
281
282func (s *splashCmp) isProviderConfigured(providerID string) bool {
283	cfg := config.Get()
284	if _, ok := cfg.Providers[providerID]; ok {
285		return true
286	}
287	return false
288}
289
290// View implements SplashPage.
291func (s *splashCmp) View() tea.View {
292	t := styles.CurrentTheme()
293	var cursor *tea.Cursor
294
295	var content string
296	switch s.state {
297	case SplashScreenStateOnboarding:
298		// Show logo and model selector
299		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
300		modelListView := s.modelList.View()
301		cursor = s.moveCursor(modelListView.Cursor())
302		modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
303			lipgloss.JoinVertical(
304				lipgloss.Left,
305				t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Choose a Model"),
306				"",
307				modelListView.String(),
308			),
309		)
310		content = lipgloss.JoinVertical(
311			lipgloss.Left,
312			s.logoRendered,
313			modelSelector,
314		)
315	case SplashScreenStateInitialize:
316		t := styles.CurrentTheme()
317
318		titleStyle := t.S().Base.Foreground(t.FgBase)
319		bodyStyle := t.S().Base.Foreground(t.FgMuted)
320		shortcutStyle := t.S().Base.Foreground(t.Success)
321
322		initText := lipgloss.JoinVertical(
323			lipgloss.Left,
324			titleStyle.Render("Would you like to initialize this project?"),
325			"",
326			bodyStyle.Render("When I initialize your codebase I examine the project and put the"),
327			bodyStyle.Render("result into a CRUSH.md file which serves as general context."),
328			"",
329			bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."),
330			"",
331			bodyStyle.Render("Would you like to initialize now?"),
332		)
333
334		yesButton := core.SelectableButton(core.ButtonOpts{
335			Text:           "Yep!",
336			UnderlineIndex: 0,
337			Selected:       !s.selectedNo,
338		})
339
340		noButton := core.SelectableButton(core.ButtonOpts{
341			Text:           "Nope",
342			UnderlineIndex: 0,
343			Selected:       s.selectedNo,
344		})
345
346		buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, "  ", noButton)
347
348		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
349
350		initContent := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
351			lipgloss.JoinVertical(
352				lipgloss.Left,
353				initText,
354				"",
355				buttons,
356			),
357		)
358
359		content = lipgloss.JoinVertical(
360			lipgloss.Left,
361			s.logoRendered,
362			initContent,
363		)
364
365	default:
366		// Show just the logo for other states
367		content = s.logoRendered
368	}
369
370	view := tea.NewView(
371		t.S().Base.
372			Width(s.width).
373			Height(s.height).
374			PaddingTop(SplashScreenPaddingY).
375			PaddingLeft(SplashScreenPaddingX).
376			PaddingRight(SplashScreenPaddingX).
377			PaddingBottom(SplashScreenPaddingY).
378			Render(content),
379	)
380
381	view.SetCursor(cursor)
382	return view
383}
384
385func (s *splashCmp) logoBlock() string {
386	t := styles.CurrentTheme()
387	const padding = 2
388	return logo.Render(version.Version, false, logo.Opts{
389		FieldColor:   t.Primary,
390		TitleColorA:  t.Secondary,
391		TitleColorB:  t.Primary,
392		CharmColor:   t.Secondary,
393		VersionColor: t.Primary,
394		Width:        s.width - (SplashScreenPaddingX * 2),
395	})
396}
397
398func (m *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
399	if cursor == nil {
400		return nil
401	}
402	offset := m.cursorRow
403	cursor.Y += offset
404	cursor.X = cursor.X + 3 // 3 for padding
405	return cursor
406}
407
408// Bindings implements SplashPage.
409func (s *splashCmp) Bindings() []key.Binding {
410	if s.state == SplashScreenStateOnboarding {
411		return []key.Binding{
412			s.keyMap.Select,
413			s.keyMap.Next,
414			s.keyMap.Previous,
415		}
416	} else if s.state == SplashScreenStateInitialize {
417		return []key.Binding{
418			s.keyMap.Select,
419			s.keyMap.Yes,
420			s.keyMap.No,
421			s.keyMap.Tab,
422			s.keyMap.LeftRight,
423		}
424	}
425	return []key.Binding{}
426}