1package splash
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "charm.land/bubbles/v2/key"
9 "charm.land/bubbles/v2/spinner"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "git.secluded.site/crush/internal/agent"
13 "git.secluded.site/crush/internal/config"
14 "git.secluded.site/crush/internal/home"
15 "git.secluded.site/crush/internal/tui/components/chat"
16 "git.secluded.site/crush/internal/tui/components/core"
17 "git.secluded.site/crush/internal/tui/components/core/layout"
18 "git.secluded.site/crush/internal/tui/components/dialogs/models"
19 "git.secluded.site/crush/internal/tui/components/logo"
20 lspcomponent "git.secluded.site/crush/internal/tui/components/lsp"
21 "git.secluded.site/crush/internal/tui/components/mcp"
22 "git.secluded.site/crush/internal/tui/exp/list"
23 "git.secluded.site/crush/internal/tui/styles"
24 "git.secluded.site/crush/internal/tui/util"
25 "git.secluded.site/crush/internal/version"
26 "github.com/charmbracelet/catwalk/pkg/catwalk"
27)
28
29type Splash interface {
30 util.Model
31 layout.Sizeable
32 layout.Help
33 Cursor() *tea.Cursor
34 // SetOnboarding controls whether the splash shows model selection UI
35 SetOnboarding(bool)
36 // SetProjectInit controls whether the splash shows project initialization prompt
37 SetProjectInit(bool)
38
39 // Showing API key input
40 IsShowingAPIKey() bool
41
42 // IsAPIKeyValid returns whether the API key is valid
43 IsAPIKeyValid() bool
44}
45
46const (
47 SplashScreenPaddingY = 1 // Padding Y for the splash screen
48
49 LogoGap = 6
50)
51
52// OnboardingCompleteMsg is sent when onboarding is complete
53type (
54 OnboardingCompleteMsg struct{}
55 SubmitAPIKeyMsg struct{}
56)
57
58type splashCmp struct {
59 width, height int
60 keyMap KeyMap
61 logoRendered string
62
63 // State
64 isOnboarding bool
65 needsProjectInit bool
66 needsAPIKey bool
67 selectedNo bool
68
69 listHeight int
70 modelList *models.ModelListComponent
71 apiKeyInput *models.APIKeyInput
72 selectedModel *models.ModelOption
73 isAPIKeyValid bool
74 apiKeyValue string
75}
76
77func New() Splash {
78 keyMap := DefaultKeyMap()
79 listKeyMap := list.DefaultKeyMap()
80 listKeyMap.Down.SetEnabled(false)
81 listKeyMap.Up.SetEnabled(false)
82 listKeyMap.HalfPageDown.SetEnabled(false)
83 listKeyMap.HalfPageUp.SetEnabled(false)
84 listKeyMap.Home.SetEnabled(false)
85 listKeyMap.End.SetEnabled(false)
86 listKeyMap.DownOneItem = keyMap.Next
87 listKeyMap.UpOneItem = keyMap.Previous
88
89 modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
90 apiKeyInput := models.NewAPIKeyInput()
91
92 return &splashCmp{
93 width: 0,
94 height: 0,
95 keyMap: keyMap,
96 logoRendered: "",
97 modelList: modelList,
98 apiKeyInput: apiKeyInput,
99 selectedNo: false,
100 }
101}
102
103func (s *splashCmp) SetOnboarding(onboarding bool) {
104 s.isOnboarding = onboarding
105}
106
107func (s *splashCmp) SetProjectInit(needsInit bool) {
108 s.needsProjectInit = needsInit
109}
110
111// GetSize implements SplashPage.
112func (s *splashCmp) GetSize() (int, int) {
113 return s.width, s.height
114}
115
116// Init implements SplashPage.
117func (s *splashCmp) Init() tea.Cmd {
118 return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init())
119}
120
121// SetSize implements SplashPage.
122func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
123 wasSmallScreen := s.isSmallScreen()
124 rerenderLogo := width != s.width
125 s.height = height
126 s.width = width
127 if rerenderLogo || wasSmallScreen != s.isSmallScreen() {
128 s.logoRendered = s.logoBlock()
129 }
130 // remove padding, logo height, gap, title space
131 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
132 listWidth := min(60, width)
133 s.apiKeyInput.SetWidth(width - 2)
134 return s.modelList.SetSize(listWidth, s.listHeight)
135}
136
137// Update implements SplashPage.
138func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
139 switch msg := msg.(type) {
140 case tea.WindowSizeMsg:
141 return s, s.SetSize(msg.Width, msg.Height)
142 case models.APIKeyStateChangeMsg:
143 u, cmd := s.apiKeyInput.Update(msg)
144 s.apiKeyInput = u.(*models.APIKeyInput)
145 if msg.State == models.APIKeyInputStateVerified {
146 return s, tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
147 return SubmitAPIKeyMsg{}
148 })
149 }
150 return s, cmd
151 case SubmitAPIKeyMsg:
152 if s.isAPIKeyValid {
153 return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
154 }
155 case tea.KeyPressMsg:
156 switch {
157 case key.Matches(msg, s.keyMap.Back):
158 if s.isAPIKeyValid {
159 return s, nil
160 }
161 if s.needsAPIKey {
162 // Go back to model selection
163 s.needsAPIKey = false
164 s.selectedModel = nil
165 s.isAPIKeyValid = false
166 s.apiKeyValue = ""
167 s.apiKeyInput.Reset()
168 return s, nil
169 }
170 case key.Matches(msg, s.keyMap.Select):
171 if s.isAPIKeyValid {
172 return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
173 }
174 if s.isOnboarding && !s.needsAPIKey {
175 selectedItem := s.modelList.SelectedModel()
176 if selectedItem == nil {
177 return s, nil
178 }
179 if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
180 cmd := s.setPreferredModel(*selectedItem)
181 s.isOnboarding = false
182 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
183 } else {
184 // Provider not configured, show API key input
185 s.needsAPIKey = true
186 s.selectedModel = selectedItem
187 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
188 return s, nil
189 }
190 } else if s.needsAPIKey {
191 // Handle API key submission
192 s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
193 if s.apiKeyValue == "" {
194 return s, nil
195 }
196
197 provider, err := s.getProvider(s.selectedModel.Provider.ID)
198 if err != nil || provider == nil {
199 return s, util.ReportError(fmt.Errorf("provider %s not found", s.selectedModel.Provider.ID))
200 }
201 providerConfig := config.ProviderConfig{
202 ID: string(s.selectedModel.Provider.ID),
203 Name: s.selectedModel.Provider.Name,
204 APIKey: s.apiKeyValue,
205 Type: provider.Type,
206 BaseURL: provider.APIEndpoint,
207 }
208 return s, tea.Sequence(
209 util.CmdHandler(models.APIKeyStateChangeMsg{
210 State: models.APIKeyInputStateVerifying,
211 }),
212 func() tea.Msg {
213 start := time.Now()
214 err := providerConfig.TestConnection(config.Get().Resolver())
215 // intentionally wait for at least 750ms to make sure the user sees the spinner
216 elapsed := time.Since(start)
217 if elapsed < 750*time.Millisecond {
218 time.Sleep(750*time.Millisecond - elapsed)
219 }
220 if err == nil {
221 s.isAPIKeyValid = true
222 return models.APIKeyStateChangeMsg{
223 State: models.APIKeyInputStateVerified,
224 }
225 }
226 return models.APIKeyStateChangeMsg{
227 State: models.APIKeyInputStateError,
228 }
229 },
230 )
231 } else if s.needsProjectInit {
232 return s, s.initializeProject()
233 }
234 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
235 if s.needsAPIKey {
236 u, cmd := s.apiKeyInput.Update(msg)
237 s.apiKeyInput = u.(*models.APIKeyInput)
238 return s, cmd
239 }
240 if s.needsProjectInit {
241 s.selectedNo = !s.selectedNo
242 return s, nil
243 }
244 case key.Matches(msg, s.keyMap.Yes):
245 if s.needsAPIKey {
246 u, cmd := s.apiKeyInput.Update(msg)
247 s.apiKeyInput = u.(*models.APIKeyInput)
248 return s, cmd
249 }
250 if s.isOnboarding {
251 u, cmd := s.modelList.Update(msg)
252 s.modelList = u
253 return s, cmd
254 }
255 if s.needsProjectInit {
256 s.selectedNo = false
257 return s, s.initializeProject()
258 }
259 case key.Matches(msg, s.keyMap.No):
260 if s.needsAPIKey {
261 u, cmd := s.apiKeyInput.Update(msg)
262 s.apiKeyInput = u.(*models.APIKeyInput)
263 return s, cmd
264 }
265 if s.isOnboarding {
266 u, cmd := s.modelList.Update(msg)
267 s.modelList = u
268 return s, cmd
269 }
270 if s.needsProjectInit {
271 s.selectedNo = true
272 return s, s.initializeProject()
273 }
274 default:
275 if s.needsAPIKey {
276 u, cmd := s.apiKeyInput.Update(msg)
277 s.apiKeyInput = u.(*models.APIKeyInput)
278 return s, cmd
279 } else if s.isOnboarding {
280 u, cmd := s.modelList.Update(msg)
281 s.modelList = u
282 return s, cmd
283 }
284 }
285 case tea.PasteMsg:
286 if s.needsAPIKey {
287 u, cmd := s.apiKeyInput.Update(msg)
288 s.apiKeyInput = u.(*models.APIKeyInput)
289 return s, cmd
290 } else if s.isOnboarding {
291 var cmd tea.Cmd
292 s.modelList, cmd = s.modelList.Update(msg)
293 return s, cmd
294 }
295 case spinner.TickMsg:
296 u, cmd := s.apiKeyInput.Update(msg)
297 s.apiKeyInput = u.(*models.APIKeyInput)
298 return s, cmd
299 }
300 return s, nil
301}
302
303func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
304 if s.selectedModel == nil {
305 return nil
306 }
307
308 cfg := config.Get()
309 err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
310 if err != nil {
311 return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
312 }
313
314 // Reset API key state and continue with model selection
315 s.needsAPIKey = false
316 cmd := s.setPreferredModel(*s.selectedModel)
317 s.isOnboarding = false
318 s.selectedModel = nil
319 s.isAPIKeyValid = false
320
321 return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
322}
323
324func (s *splashCmp) initializeProject() tea.Cmd {
325 s.needsProjectInit = false
326
327 if err := config.MarkProjectInitialized(); err != nil {
328 return util.ReportError(err)
329 }
330 var cmds []tea.Cmd
331
332 cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{}))
333 if !s.selectedNo {
334 initPrompt, err := agent.InitializePrompt(*config.Get())
335 if err != nil {
336 return util.ReportError(err)
337 }
338 cmds = append(cmds,
339 util.CmdHandler(chat.SessionClearedMsg{}),
340 util.CmdHandler(chat.SendMsg{
341 Text: initPrompt,
342 }),
343 )
344 }
345 return tea.Sequence(cmds...)
346}
347
348func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
349 cfg := config.Get()
350 model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
351 if model == nil {
352 return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
353 }
354
355 selectedModel := config.SelectedModel{
356 Model: selectedItem.Model.ID,
357 Provider: string(selectedItem.Provider.ID),
358 ReasoningEffort: model.DefaultReasoningEffort,
359 MaxTokens: model.DefaultMaxTokens,
360 }
361
362 err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
363 if err != nil {
364 return util.ReportError(err)
365 }
366
367 // Now lets automatically setup the small model
368 knownProvider, err := s.getProvider(selectedItem.Provider.ID)
369 if err != nil {
370 return util.ReportError(err)
371 }
372 if knownProvider == nil {
373 // for local provider we just use the same model
374 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
375 if err != nil {
376 return util.ReportError(err)
377 }
378 } else {
379 smallModel := knownProvider.DefaultSmallModelID
380 model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
381 // should never happen
382 if model == nil {
383 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
384 if err != nil {
385 return util.ReportError(err)
386 }
387 return nil
388 }
389 smallSelectedModel := config.SelectedModel{
390 Model: smallModel,
391 Provider: string(selectedItem.Provider.ID),
392 ReasoningEffort: model.DefaultReasoningEffort,
393 MaxTokens: model.DefaultMaxTokens,
394 }
395 err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
396 if err != nil {
397 return util.ReportError(err)
398 }
399 }
400 cfg.SetupAgents()
401 return nil
402}
403
404func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
405 cfg := config.Get()
406 providers, err := config.Providers(cfg)
407 if err != nil {
408 return nil, err
409 }
410 for _, p := range providers {
411 if p.ID == providerID {
412 return &p, nil
413 }
414 }
415 return nil, nil
416}
417
418func (s *splashCmp) isProviderConfigured(providerID string) bool {
419 cfg := config.Get()
420 if _, ok := cfg.Providers.Get(providerID); ok {
421 return true
422 }
423 return false
424}
425
426func (s *splashCmp) View() string {
427 t := styles.CurrentTheme()
428 var content string
429 if s.needsAPIKey {
430 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
431 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
432 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
433 lipgloss.JoinVertical(
434 lipgloss.Left,
435 apiKeyView,
436 ),
437 )
438 content = lipgloss.JoinVertical(
439 lipgloss.Left,
440 s.logoRendered,
441 apiKeySelector,
442 )
443 } else if s.isOnboarding {
444 modelListView := s.modelList.View()
445 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
446 modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
447 lipgloss.JoinVertical(
448 lipgloss.Left,
449 t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("To start, let’s choose a provider and model."),
450 "",
451 modelListView,
452 ),
453 )
454 content = lipgloss.JoinVertical(
455 lipgloss.Left,
456 s.logoRendered,
457 modelSelector,
458 )
459 } else if s.needsProjectInit {
460 titleStyle := t.S().Base.Foreground(t.FgBase)
461 pathStyle := t.S().Base.Foreground(t.Success).PaddingLeft(2)
462 bodyStyle := t.S().Base.Foreground(t.FgMuted)
463 shortcutStyle := t.S().Base.Foreground(t.Success)
464
465 initFile := config.Get().Options.InitializeAs
466 initText := lipgloss.JoinVertical(
467 lipgloss.Left,
468 titleStyle.Render("Would you like to initialize this project?"),
469 "",
470 pathStyle.Render(s.cwd()),
471 "",
472 bodyStyle.Render("When I initialize your codebase I examine the project and put the"),
473 bodyStyle.Render(fmt.Sprintf("result into an %s file which serves as general context.", initFile)),
474 "",
475 bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."),
476 "",
477 bodyStyle.Render("Would you like to initialize now?"),
478 )
479
480 yesButton := core.SelectableButton(core.ButtonOpts{
481 Text: "Yep!",
482 UnderlineIndex: 0,
483 Selected: !s.selectedNo,
484 })
485
486 noButton := core.SelectableButton(core.ButtonOpts{
487 Text: "Nope",
488 UnderlineIndex: 0,
489 Selected: s.selectedNo,
490 })
491
492 buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton)
493 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
494
495 initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render(
496 lipgloss.JoinVertical(
497 lipgloss.Left,
498 initText,
499 "",
500 buttons,
501 ),
502 )
503
504 content = lipgloss.JoinVertical(
505 lipgloss.Left,
506 s.logoRendered,
507 "",
508 initContent,
509 )
510 } else {
511 parts := []string{
512 s.logoRendered,
513 s.infoSection(),
514 }
515 content = lipgloss.JoinVertical(lipgloss.Left, parts...)
516 }
517
518 return t.S().Base.
519 Width(s.width).
520 Height(s.height).
521 PaddingTop(SplashScreenPaddingY).
522 PaddingBottom(SplashScreenPaddingY).
523 Render(content)
524}
525
526func (s *splashCmp) Cursor() *tea.Cursor {
527 if s.needsAPIKey {
528 cursor := s.apiKeyInput.Cursor()
529 if cursor != nil {
530 return s.moveCursor(cursor)
531 }
532 } else if s.isOnboarding {
533 cursor := s.modelList.Cursor()
534 if cursor != nil {
535 return s.moveCursor(cursor)
536 }
537 } else {
538 return nil
539 }
540 return nil
541}
542
543func (s *splashCmp) isSmallScreen() bool {
544 // Consider a screen small if either the width is less than 40 or if the
545 // height is less than 20
546 return s.width < 55 || s.height < 20
547}
548
549func (s *splashCmp) infoSection() string {
550 t := styles.CurrentTheme()
551 infoStyle := t.S().Base.PaddingLeft(2)
552 if s.isSmallScreen() {
553 infoStyle = infoStyle.MarginTop(1)
554 }
555 return infoStyle.Render(
556 lipgloss.JoinVertical(
557 lipgloss.Left,
558 s.cwdPart(),
559 "",
560 s.currentModelBlock(),
561 "",
562 lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
563 "",
564 ),
565 )
566}
567
568func (s *splashCmp) logoBlock() string {
569 t := styles.CurrentTheme()
570 logoStyle := t.S().Base.Padding(0, 2).Width(s.width)
571 if s.isSmallScreen() {
572 // If the width is too small, render a smaller version of the logo
573 // NOTE: 20 is not correct because [splashCmp.height] is not the
574 // *actual* window height, instead, it is the height of the splash
575 // component and that depends on other variables like compact mode and
576 // the height of the editor.
577 return logoStyle.Render(
578 logo.SmallRender(s.width - logoStyle.GetHorizontalFrameSize()),
579 )
580 }
581 return logoStyle.Render(
582 logo.Render(version.Version, false, logo.Opts{
583 FieldColor: t.Primary,
584 TitleColorA: t.Secondary,
585 TitleColorB: t.Primary,
586 CharmColor: t.Secondary,
587 VersionColor: t.Primary,
588 Width: s.width - logoStyle.GetHorizontalFrameSize(),
589 }),
590 )
591}
592
593func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
594 if cursor == nil {
595 return nil
596 }
597 // Calculate the correct Y offset based on current state
598 logoHeight := lipgloss.Height(s.logoRendered)
599 if s.needsAPIKey {
600 infoSectionHeight := lipgloss.Height(s.infoSection())
601 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
602 remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
603 offset := baseOffset + remainingHeight
604 cursor.Y += offset
605 cursor.X = cursor.X + 1
606 } else if s.isOnboarding {
607 offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
608 cursor.Y += offset
609 cursor.X = cursor.X + 1
610 }
611
612 return cursor
613}
614
615func (s *splashCmp) logoGap() int {
616 if s.height > 35 {
617 return LogoGap
618 }
619 return 0
620}
621
622// Bindings implements SplashPage.
623func (s *splashCmp) Bindings() []key.Binding {
624 if s.needsAPIKey {
625 return []key.Binding{
626 s.keyMap.Select,
627 s.keyMap.Back,
628 }
629 } else if s.isOnboarding {
630 return []key.Binding{
631 s.keyMap.Select,
632 s.keyMap.Next,
633 s.keyMap.Previous,
634 }
635 } else if s.needsProjectInit {
636 return []key.Binding{
637 s.keyMap.Select,
638 s.keyMap.Yes,
639 s.keyMap.No,
640 s.keyMap.Tab,
641 s.keyMap.LeftRight,
642 }
643 }
644 return []key.Binding{}
645}
646
647func (s *splashCmp) getMaxInfoWidth() int {
648 return min(s.width-2, 90) // 2 for left padding
649}
650
651func (s *splashCmp) cwdPart() string {
652 t := styles.CurrentTheme()
653 maxWidth := s.getMaxInfoWidth()
654 return t.S().Muted.Width(maxWidth).Render(s.cwd())
655}
656
657func (s *splashCmp) cwd() string {
658 return home.Short(config.Get().WorkingDir())
659}
660
661func LSPList(maxWidth int) []string {
662 return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{
663 MaxWidth: maxWidth,
664 ShowSection: false,
665 })
666}
667
668func (s *splashCmp) lspBlock() string {
669 t := styles.CurrentTheme()
670 maxWidth := s.getMaxInfoWidth() / 2
671 section := t.S().Subtle.Render("LSPs")
672 lspList := append([]string{section, ""}, LSPList(maxWidth-1)...)
673 return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
674 lipgloss.JoinVertical(
675 lipgloss.Left,
676 lspList...,
677 ),
678 )
679}
680
681func MCPList(maxWidth int) []string {
682 return mcp.RenderMCPList(mcp.RenderOptions{
683 MaxWidth: maxWidth,
684 ShowSection: false,
685 })
686}
687
688func (s *splashCmp) mcpBlock() string {
689 t := styles.CurrentTheme()
690 maxWidth := s.getMaxInfoWidth() / 2
691 section := t.S().Subtle.Render("MCPs")
692 mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...)
693 return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
694 lipgloss.JoinVertical(
695 lipgloss.Left,
696 mcpList...,
697 ),
698 )
699}
700
701func (s *splashCmp) currentModelBlock() string {
702 cfg := config.Get()
703 agentCfg := cfg.Agents[config.AgentCoder]
704 model := config.Get().GetModelByType(agentCfg.Model)
705 if model == nil {
706 return ""
707 }
708 t := styles.CurrentTheme()
709 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
710 modelName := t.S().Text.Render(model.Name)
711 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
712 parts := []string{
713 modelInfo,
714 }
715
716 return lipgloss.JoinVertical(
717 lipgloss.Left,
718 parts...,
719 )
720}
721
722func (s *splashCmp) IsShowingAPIKey() bool {
723 return s.needsAPIKey
724}
725
726func (s *splashCmp) IsAPIKeyValid() bool {
727 return s.isAPIKeyValid
728}