@@ -9,6 +9,7 @@ import (
99 "charm.land/bubbles/v2/spinner"
1010 tea "charm.land/bubbletea/v2"
1111 "charm.land/lipgloss/v2"
12+ "github.com/atotto/clipboard"
1213 "github.com/charmbracelet/catwalk/pkg/catwalk"
1314 "github.com/charmbracelet/crush/internal/agent"
1415 "github.com/charmbracelet/crush/internal/config"
@@ -16,6 +17,7 @@ import (
1617 "github.com/charmbracelet/crush/internal/tui/components/chat"
1718 "github.com/charmbracelet/crush/internal/tui/components/core"
1819 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
1921 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
2022 "github.com/charmbracelet/crush/internal/tui/components/logo"
2123 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
@@ -41,6 +43,18 @@ type Splash interface {
4143
4244 // IsAPIKeyValid returns whether the API key is valid
4345 IsAPIKeyValid() bool
46+
47+ // IsShowingClaudeAuthMethodChooser returns whether showing Claude auth method chooser
48+ IsShowingClaudeAuthMethodChooser() bool
49+
50+ // IsShowingClaudeOAuth2 returns whether showing Claude OAuth2 flow
51+ IsShowingClaudeOAuth2() bool
52+
53+ // IsClaudeOAuthURLState returns whether in OAuth URL state
54+ IsClaudeOAuthURLState() bool
55+
56+ // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete
57+ IsClaudeOAuthComplete() bool
4458}
4559
4660const (
@@ -72,6 +86,12 @@ type splashCmp struct {
7286 selectedModel *models.ModelOption
7387 isAPIKeyValid bool
7488 apiKeyValue string
89+
90+ // Claude state
91+ claudeAuthMethodChooser *claude.AuthMethodChooser
92+ claudeOAuth2 *claude.OAuth2
93+ showClaudeAuthMethodChooser bool
94+ showClaudeOAuth2 bool
7595}
7696
7797func New() Splash {
@@ -97,6 +117,9 @@ func New() Splash {
97117 modelList: modelList,
98118 apiKeyInput: apiKeyInput,
99119 selectedNo: false,
120+
121+ claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
122+ claudeOAuth2: claude.NewOAuth2(),
100123 }
101124}
102125
@@ -115,7 +138,12 @@ func (s *splashCmp) GetSize() (int, int) {
115138
116139// Init implements SplashPage.
117140func (s *splashCmp) Init() tea.Cmd {
118− return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init())
141+ return tea.Batch(
142+ s.modelList.Init(),
143+ s.apiKeyInput.Init(),
144+ s.claudeAuthMethodChooser.Init(),
145+ s.claudeOAuth2.Init(),
146+ )
119147}
120148
121149// SetSize implements SplashPage.
@@ -131,6 +159,7 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
131159 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
132160 listWidth := min(60, width)
133161 s.apiKeyInput.SetWidth(width - 2)
162+ s.claudeAuthMethodChooser.SetWidth(min(width-2, 60))
134163 return s.modelList.SetSize(listWidth, s.listHeight)
135164}
136165
@@ -139,6 +168,28 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
139168 switch msg := msg.(type) {
140169 case tea.WindowSizeMsg:
141170 return s, s.SetSize(msg.Width, msg.Height)
171+ case claude.ValidationCompletedMsg:
172+ var cmds []tea.Cmd
173+ u, cmd := s.claudeOAuth2.Update(msg)
174+ s.claudeOAuth2 = u.(*claude.OAuth2)
175+ cmds = append(cmds, cmd)
176+
177+ if msg.State == claude.OAuthValidationStateValid {
178+ cmds = append(
179+ cmds,
180+ s.saveAPIKeyAndContinue(msg.Token, false),
181+ func() tea.Msg {
182+ time.Sleep(5 * time.Second)
183+ return claude.AuthenticationCompleteMsg{}
184+ },
185+ )
186+ }
187+
188+ return s, tea.Batch(cmds...)
189+ case claude.AuthenticationCompleteMsg:
190+ s.showClaudeAuthMethodChooser = false
191+ s.showClaudeOAuth2 = false
192+ return s, util.CmdHandler(OnboardingCompleteMsg{})
142193 case models.APIKeyStateChangeMsg:
143194 u, cmd := s.apiKeyInput.Update(msg)
144195 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -150,16 +201,48 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
150201 return s, cmd
151202 case SubmitAPIKeyMsg:
152203 if s.isAPIKeyValid {
153− return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
204+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
154205 }
155206 case tea.KeyPressMsg:
156207 switch {
208+ case key.Matches(msg, s.keyMap.Copy):
209+ if s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL {
210+ return s, tea.Sequence(
211+ tea.SetClipboard(s.claudeOAuth2.URL),
212+ func() tea.Msg {
213+ _ = clipboard.WriteAll(s.claudeOAuth2.URL)
214+ return nil
215+ },
216+ util.ReportInfo("URL copied to clipboard"),
217+ )
218+ } else if s.showClaudeAuthMethodChooser {
219+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
220+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
221+ return s, cmd
222+ } else if s.showClaudeOAuth2 {
223+ u, cmd := s.claudeOAuth2.Update(msg)
224+ s.claudeOAuth2 = u.(*claude.OAuth2)
225+ return s, cmd
226+ }
157227 case key.Matches(msg, s.keyMap.Back):
228+ if s.showClaudeAuthMethodChooser {
229+ s.claudeAuthMethodChooser.SetDefaults()
230+ s.showClaudeAuthMethodChooser = false
231+ return s, nil
232+ }
233+ if s.showClaudeOAuth2 {
234+ s.claudeOAuth2.SetDefaults()
235+ s.showClaudeOAuth2 = false
236+ s.showClaudeAuthMethodChooser = true
237+ return s, nil
238+ }
158239 if s.isAPIKeyValid {
159240 return s, nil
160241 }
161242 if s.needsAPIKey {
162− // Go back to model selection
243+ if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
244+ s.showClaudeAuthMethodChooser = true
245+ }
163246 s.needsAPIKey = false
164247 s.selectedModel = nil
165248 s.isAPIKeyValid = false
@@ -168,8 +251,32 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
168251 return s, nil
169252 }
170253 case key.Matches(msg, s.keyMap.Select):
254+ if s.showClaudeAuthMethodChooser {
255+ selectedItem := s.modelList.SelectedModel()
256+ if selectedItem == nil {
257+ return s, nil
258+ }
259+
260+ switch s.claudeAuthMethodChooser.State {
261+ case claude.AuthMethodAPIKey:
262+ s.showClaudeAuthMethodChooser = false
263+ s.needsAPIKey = true
264+ s.selectedModel = selectedItem
265+ s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
266+ case claude.AuthMethodOAuth2:
267+ s.selectedModel = selectedItem
268+ s.showClaudeAuthMethodChooser = false
269+ s.showClaudeOAuth2 = true
270+ }
271+ return s, nil
272+ }
273+ if s.showClaudeOAuth2 {
274+ m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
275+ s.claudeOAuth2 = m2.(*claude.OAuth2)
276+ return s, cmd2
277+ }
171278 if s.isAPIKeyValid {
172− return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
279+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
173280 }
174281 if s.isOnboarding && !s.needsAPIKey {
175282 selectedItem := s.modelList.SelectedModel()
@@ -181,6 +288,10 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
181288 s.isOnboarding = false
182289 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
183290 } else {
291+ if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
292+ s.showClaudeAuthMethodChooser = true
293+ return s, nil
294+ }
184295 // Provider not configured, show API key input
185296 s.needsAPIKey = true
186297 s.selectedModel = selectedItem
@@ -232,6 +343,10 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
232343 return s, s.initializeProject()
233344 }
234345 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
346+ if s.showClaudeAuthMethodChooser {
347+ s.claudeAuthMethodChooser.ToggleChoice()
348+ return s, nil
349+ }
235350 if s.needsAPIKey {
236351 u, cmd := s.apiKeyInput.Update(msg)
237352 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -272,7 +387,15 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
272387 return s, s.initializeProject()
273388 }
274389 default:
275− if s.needsAPIKey {
390+ if s.showClaudeAuthMethodChooser {
391+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
392+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
393+ return s, cmd
394+ } else if s.showClaudeOAuth2 {
395+ u, cmd := s.claudeOAuth2.Update(msg)
396+ s.claudeOAuth2 = u.(*claude.OAuth2)
397+ return s, cmd
398+ } else if s.needsAPIKey {
276399 u, cmd := s.apiKeyInput.Update(msg)
277400 s.apiKeyInput = u.(*models.APIKeyInput)
278401 return s, cmd
@@ -283,7 +406,11 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
283406 }
284407 }
285408 case tea.PasteMsg:
286− if s.needsAPIKey {
409+ if s.showClaudeOAuth2 {
410+ u, cmd := s.claudeOAuth2.Update(msg)
411+ s.claudeOAuth2 = u.(*claude.OAuth2)
412+ return s, cmd
413+ } else if s.needsAPIKey {
287414 u, cmd := s.apiKeyInput.Update(msg)
288415 s.apiKeyInput = u.(*models.APIKeyInput)
289416 return s, cmd
@@ -293,14 +420,20 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
293420 return s, cmd
294421 }
295422 case spinner.TickMsg:
296− u, cmd := s.apiKeyInput.Update(msg)
297− s.apiKeyInput = u.(*models.APIKeyInput)
298− return s, cmd
423+ if s.showClaudeOAuth2 {
424+ u, cmd := s.claudeOAuth2.Update(msg)
425+ s.claudeOAuth2 = u.(*claude.OAuth2)
426+ return s, cmd
427+ } else {
428+ u, cmd := s.apiKeyInput.Update(msg)
429+ s.apiKeyInput = u.(*models.APIKeyInput)
430+ return s, cmd
431+ }
299432 }
300433 return s, nil
301434}
302435
303−func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
436+func (s *splashCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
304437 if s.selectedModel == nil {
305438 return nil
306439 }
@@ -318,7 +451,10 @@ func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
318451 s.selectedModel = nil
319452 s.isAPIKeyValid = false
320453
321− return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
454+ if close {
455+ return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
456+ }
457+ return cmd
322458}
323459
324460func (s *splashCmp) initializeProject() tea.Cmd {
@@ -426,7 +562,39 @@ func (s *splashCmp) isProviderConfigured(providerID string) bool {
426562func (s *splashCmp) View() string {
427563 t := styles.CurrentTheme()
428564 var content string
429− if s.needsAPIKey {
565+ if s.showClaudeAuthMethodChooser {
566+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
567+ chooserView := s.claudeAuthMethodChooser.View()
568+ authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
569+ lipgloss.JoinVertical(
570+ lipgloss.Left,
571+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
572+ "",
573+ chooserView,
574+ ),
575+ )
576+ content = lipgloss.JoinVertical(
577+ lipgloss.Left,
578+ s.logoRendered,
579+ authMethodSelector,
580+ )
581+ } else if s.showClaudeOAuth2 {
582+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
583+ oauth2View := s.claudeOAuth2.View()
584+ oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
585+ lipgloss.JoinVertical(
586+ lipgloss.Left,
587+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
588+ "",
589+ oauth2View,
590+ ),
591+ )
592+ content = lipgloss.JoinVertical(
593+ lipgloss.Left,
594+ s.logoRendered,
595+ oauthSelector,
596+ )
597+ } else if s.needsAPIKey {
430598 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
431599 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
432600 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
@@ -524,6 +692,16 @@ func (s *splashCmp) View() string {
524692}
525693
526694func (s *splashCmp) Cursor() *tea.Cursor {
695+ if s.showClaudeAuthMethodChooser {
696+ return nil
697+ }
698+ if s.showClaudeOAuth2 {
699+ if cursor := s.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
700+ cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
701+ return s.moveCursor(cursor)
702+ }
703+ return nil
704+ }
527705 if s.needsAPIKey {
528706 cursor := s.apiKeyInput.Cursor()
529707 if cursor != nil {
@@ -596,17 +774,23 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
596774 }
597775 // Calculate the correct Y offset based on current state
598776 logoHeight := lipgloss.Height(s.logoRendered)
599− if s.needsAPIKey {
777+ if s.needsAPIKey || s.showClaudeOAuth2 {
778+ var view string
779+ if s.needsAPIKey {
780+ view = s.apiKeyInput.View()
781+ } else {
782+ view = s.claudeOAuth2.View()
783+ }
600784 infoSectionHeight := lipgloss.Height(s.infoSection())
601785 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
602− remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
786+ remainingHeight := s.height - baseOffset - lipgloss.Height(view) - SplashScreenPaddingY
603787 offset := baseOffset + remainingHeight
604788 cursor.Y += offset
605− cursor.X = cursor.X + 1
789+ cursor.X += 1
606790 } else if s.isOnboarding {
607791 offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
608792 cursor.Y += offset
609− cursor.X = cursor.X + 1
793+ cursor.X += 1
610794 }
611795
612796 return cursor
@@ -621,7 +805,21 @@ func (s *splashCmp) logoGap() int {
621805
622806// Bindings implements SplashPage.
623807func (s *splashCmp) Bindings() []key.Binding {
624− if s.needsAPIKey {
808+ if s.showClaudeAuthMethodChooser {
809+ return []key.Binding{
810+ s.keyMap.Select,
811+ s.keyMap.Tab,
812+ s.keyMap.Back,
813+ }
814+ } else if s.showClaudeOAuth2 {
815+ bindings := []key.Binding{
816+ s.keyMap.Select,
817+ }
818+ if s.claudeOAuth2.State == claude.OAuthStateURL {
819+ bindings = append(bindings, s.keyMap.Copy)
820+ }
821+ return bindings
822+ } else if s.needsAPIKey {
625823 return []key.Binding{
626824 s.keyMap.Select,
627825 s.keyMap.Back,
@@ -726,3 +924,19 @@ func (s *splashCmp) IsShowingAPIKey() bool {
726924func (s *splashCmp) IsAPIKeyValid() bool {
727925 return s.isAPIKeyValid
728926}
927+
928+func (s *splashCmp) IsShowingClaudeAuthMethodChooser() bool {
929+ return s.showClaudeAuthMethodChooser
930+}
931+
932+func (s *splashCmp) IsShowingClaudeOAuth2() bool {
933+ return s.showClaudeOAuth2
934+}
935+
936+func (s *splashCmp) IsClaudeOAuthURLState() bool {
937+ return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL
938+}
939+
940+func (s *splashCmp) IsClaudeOAuthComplete() bool {
941+ return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid
942+}