@@ -1,9 +1,16 @@
11package splash
22
33import (
4+ "fmt"
5+ "slices"
6+
47 "github.com/charmbracelet/bubbles/v2/key"
58 tea "github.com/charmbracelet/bubbletea/v2"
69 "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"
714 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
815 "github.com/charmbracelet/crush/internal/tui/components/core/list"
916 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
@@ -43,6 +50,7 @@ type splashCmp struct {
4350 state SplashScreenState
4451 modelList *models.ModelListComponent
4552 cursorRow, cursorCol int
53+ selectedNo bool // true if "No" button is selected in initialize state
4654}
4755
4856func New() Splash {
@@ -67,6 +75,7 @@ func New() Splash {
6775 state: SplashScreenStateOnboarding,
6876 logoRendered: "",
6977 modelList: modelList,
78+ selectedNo: false,
7079 }
7180}
7281
@@ -83,8 +92,28 @@ func (s *splashCmp) Init() tea.Cmd {
8392 } else {
8493 s.state = SplashScreenStateReady
8594 }
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()
86115 }
87− return s.modelList.Init()
116+ return nil
88117}
89118
90119// SetSize implements SplashPage.
@@ -107,15 +136,157 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
107136 return s, s.SetSize(msg.Width, msg.Height)
108137 case tea.KeyPressMsg:
109138 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+ }
110174 default:
111− u, cmd := s.modelList.Update(msg)
112− s.modelList = u
113− return s, cmd
175+ if s.state == SplashScreenStateOnboarding {
176+ u, cmd := s.modelList.Update(msg)
177+ s.modelList = u
178+ return s, cmd
179+ }
114180 }
115181 }
116182 return s, nil
117183}
118184
185+func (s *splashCmp) initializeProject() tea.Cmd {
186+ s.state = SplashScreenStateReady
187+ prompt := `Please analyze this codebase and create a CRUSH.md file containing:
188+1. Build/lint/test commands - especially for running a single test
189+2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
190+
191+The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
192+If there's already a CRUSH.md, improve it.
193+If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
194+Add 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+
214+func (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+
269+func (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+
282+func (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+
119290// View implements SplashPage.
120291func (s *splashCmp) View() tea.View {
121292 t := styles.CurrentTheme()
@@ -141,6 +312,56 @@ func (s *splashCmp) View() tea.View {
141312 s.logoRendered,
142313 modelSelector,
143314 )
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+
144365 default:
145366 // Show just the logo for other states
146367 content = s.logoRendered
@@ -192,6 +413,14 @@ func (s *splashCmp) Bindings() []key.Binding {
192413 s.keyMap.Next,
193414 s.keyMap.Previous,
194415 }
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+ }
195424 }
196425 return []key.Binding{}
197426}