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}