@@ -9,6 +9,7 @@ import (
9 "charm.land/bubbles/v2/spinner"
9 "charm.land/bubbles/v2/spinner"
10 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
11 "charm.land/lipgloss/v2"
12+ "github.com/atotto/clipboard"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/crush/internal/agent"
14 "github.com/charmbracelet/crush/internal/agent"
14 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/config"
@@ -16,6 +17,7 @@ import (
16 "github.com/charmbracelet/crush/internal/tui/components/chat"
17 "github.com/charmbracelet/crush/internal/tui/components/chat"
17 "github.com/charmbracelet/crush/internal/tui/components/core"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
20 "github.com/charmbracelet/crush/internal/tui/components/logo"
22 "github.com/charmbracelet/crush/internal/tui/components/logo"
21 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
23 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
@@ -41,6 +43,18 @@ type Splash interface {
42 // IsAPIKeyValid returns whether the API key is valid
44 // IsAPIKeyValid returns whether the API key is valid
43 IsAPIKeyValid() bool
45 IsAPIKeyValid() bool
47+ // IsShowingClaudeAuthMethodChooser returns whether showing Claude auth method chooser
48+ IsShowingClaudeAuthMethodChooser() bool
50+ // IsShowingClaudeOAuth2 returns whether showing Claude OAuth2 flow
51+ IsShowingClaudeOAuth2() bool
53+ // IsClaudeOAuthURLState returns whether in OAuth URL state
54+ IsClaudeOAuthURLState() bool
56+ // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete
57+ IsClaudeOAuthComplete() bool
@@ -72,6 +86,12 @@ type splashCmp struct {
72 selectedModel *models.ModelOption
86 selectedModel *models.ModelOption
73 isAPIKeyValid bool
87 isAPIKeyValid bool
74 apiKeyValue string
88 apiKeyValue string
91+ claudeAuthMethodChooser *claude.AuthMethodChooser
92+ claudeOAuth2 *claude.OAuth2
93+ showClaudeAuthMethodChooser bool
94+ showClaudeOAuth2 bool
77func New() Splash {
97func New() Splash {
@@ -97,6 +117,9 @@ func New() Splash {
97 modelList: modelList,
117 modelList: modelList,
98 apiKeyInput: apiKeyInput,
118 apiKeyInput: apiKeyInput,
99 selectedNo: false,
119 selectedNo: false,
121+ claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
122+ claudeOAuth2: claude.NewOAuth2(),
@@ -115,7 +138,12 @@ func (s *splashCmp) GetSize() (int, int) {
116// Init implements SplashPage.
139// Init implements SplashPage.
117func (s *splashCmp) Init() tea.Cmd {
140func (s *splashCmp) Init() tea.Cmd {
118− return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init())
143+ s.apiKeyInput.Init(),
144+ s.claudeAuthMethodChooser.Init(),
145+ s.claudeOAuth2.Init(),
121// SetSize implements SplashPage.
149// SetSize implements SplashPage.
@@ -131,6 +159,7 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
131 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
159 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
132 listWidth := min(60, width)
160 listWidth := min(60, width)
133 s.apiKeyInput.SetWidth(width - 2)
161 s.apiKeyInput.SetWidth(width - 2)
162+ s.claudeAuthMethodChooser.SetWidth(min(width-2, 60))
134 return s.modelList.SetSize(listWidth, s.listHeight)
163 return s.modelList.SetSize(listWidth, s.listHeight)
@@ -139,6 +168,28 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
139 switch msg := msg.(type) {
168 switch msg := msg.(type) {
140 case tea.WindowSizeMsg:
169 case tea.WindowSizeMsg:
141 return s, s.SetSize(msg.Width, msg.Height)
170 return s, s.SetSize(msg.Width, msg.Height)
171+ case claude.ValidationCompletedMsg:
173+ u, cmd := s.claudeOAuth2.Update(msg)
174+ s.claudeOAuth2 = u.(*claude.OAuth2)
175+ cmds = append(cmds, cmd)
177+ if msg.State == claude.OAuthValidationStateValid {
180+ s.saveAPIKeyAndContinue(msg.Token, false),
182+ time.Sleep(5 * time.Second)
183+ return claude.AuthenticationCompleteMsg{}
188+ return s, tea.Batch(cmds...)
189+ case claude.AuthenticationCompleteMsg:
190+ s.showClaudeAuthMethodChooser = false
191+ s.showClaudeOAuth2 = false
192+ return s, util.CmdHandler(OnboardingCompleteMsg{})
142 case models.APIKeyStateChangeMsg:
193 case models.APIKeyStateChangeMsg:
143 u, cmd := s.apiKeyInput.Update(msg)
194 u, cmd := s.apiKeyInput.Update(msg)
144 s.apiKeyInput = u.(*models.APIKeyInput)
195 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -150,16 +201,48 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
150 return s, cmd
201 return s, cmd
151 case SubmitAPIKeyMsg:
202 case SubmitAPIKeyMsg:
152 if s.isAPIKeyValid {
203 if s.isAPIKeyValid {
153− return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
204+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
155 case tea.KeyPressMsg:
206 case tea.KeyPressMsg:
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),
213+ _ = clipboard.WriteAll(s.claudeOAuth2.URL)
216+ util.ReportInfo("URL copied to clipboard"),
218+ } else if s.showClaudeAuthMethodChooser {
219+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
220+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
222+ } else if s.showClaudeOAuth2 {
223+ u, cmd := s.claudeOAuth2.Update(msg)
224+ s.claudeOAuth2 = u.(*claude.OAuth2)
157 case key.Matches(msg, s.keyMap.Back):
227 case key.Matches(msg, s.keyMap.Back):
228+ if s.showClaudeAuthMethodChooser {
229+ s.claudeAuthMethodChooser.SetDefaults()
230+ s.showClaudeAuthMethodChooser = false
233+ if s.showClaudeOAuth2 {
234+ s.claudeOAuth2.SetDefaults()
235+ s.showClaudeOAuth2 = false
236+ s.showClaudeAuthMethodChooser = true
158 if s.isAPIKeyValid {
239 if s.isAPIKeyValid {
159 return s, nil
240 return s, nil
161 if s.needsAPIKey {
242 if s.needsAPIKey {
162− // Go back to model selection
243+ if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
244+ s.showClaudeAuthMethodChooser = true
163 s.needsAPIKey = false
246 s.needsAPIKey = false
164 s.selectedModel = nil
247 s.selectedModel = nil
165 s.isAPIKeyValid = false
248 s.isAPIKeyValid = false
@@ -168,8 +251,32 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
168 return s, nil
251 return s, nil
170 case key.Matches(msg, s.keyMap.Select):
253 case key.Matches(msg, s.keyMap.Select):
254+ if s.showClaudeAuthMethodChooser {
255+ selectedItem := s.modelList.SelectedModel()
256+ if selectedItem == nil {
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
273+ if s.showClaudeOAuth2 {
274+ m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
275+ s.claudeOAuth2 = m2.(*claude.OAuth2)
171 if s.isAPIKeyValid {
278 if s.isAPIKeyValid {
172− return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
279+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
174 if s.isOnboarding && !s.needsAPIKey {
281 if s.isOnboarding && !s.needsAPIKey {
175 selectedItem := s.modelList.SelectedModel()
282 selectedItem := s.modelList.SelectedModel()
@@ -181,6 +288,10 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
181 s.isOnboarding = false
288 s.isOnboarding = false
182 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
289 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
291+ if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
292+ s.showClaudeAuthMethodChooser = true
184 // Provider not configured, show API key input
295 // Provider not configured, show API key input
185 s.needsAPIKey = true
296 s.needsAPIKey = true
186 s.selectedModel = selectedItem
297 s.selectedModel = selectedItem
@@ -232,6 +343,10 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
232 return s, s.initializeProject()
343 return s, s.initializeProject()
234 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
345 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
346+ if s.showClaudeAuthMethodChooser {
347+ s.claudeAuthMethodChooser.ToggleChoice()
235 if s.needsAPIKey {
350 if s.needsAPIKey {
236 u, cmd := s.apiKeyInput.Update(msg)
351 u, cmd := s.apiKeyInput.Update(msg)
237 s.apiKeyInput = u.(*models.APIKeyInput)
352 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -272,7 +387,15 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
272 return s, s.initializeProject()
387 return s, s.initializeProject()
390+ if s.showClaudeAuthMethodChooser {
391+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
392+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
394+ } else if s.showClaudeOAuth2 {
395+ u, cmd := s.claudeOAuth2.Update(msg)
396+ s.claudeOAuth2 = u.(*claude.OAuth2)
275− if s.needsAPIKey {
398+ } else if s.needsAPIKey {
276 u, cmd := s.apiKeyInput.Update(msg)
399 u, cmd := s.apiKeyInput.Update(msg)
277 s.apiKeyInput = u.(*models.APIKeyInput)
400 s.apiKeyInput = u.(*models.APIKeyInput)
278 return s, cmd
401 return s, cmd
@@ -283,7 +406,11 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
285 case tea.PasteMsg:
408 case tea.PasteMsg:
409+ if s.showClaudeOAuth2 {
410+ u, cmd := s.claudeOAuth2.Update(msg)
411+ s.claudeOAuth2 = u.(*claude.OAuth2)
286− if s.needsAPIKey {
413+ } else if s.needsAPIKey {
287 u, cmd := s.apiKeyInput.Update(msg)
414 u, cmd := s.apiKeyInput.Update(msg)
288 s.apiKeyInput = u.(*models.APIKeyInput)
415 s.apiKeyInput = u.(*models.APIKeyInput)
289 return s, cmd
416 return s, cmd
@@ -293,14 +420,20 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
293 return s, cmd
420 return s, cmd
295 case spinner.TickMsg:
422 case spinner.TickMsg:
423+ if s.showClaudeOAuth2 {
296− u, cmd := s.apiKeyInput.Update(msg)
424+ u, cmd := s.claudeOAuth2.Update(msg)
425+ s.claudeOAuth2 = u.(*claude.OAuth2)
428+ u, cmd := s.apiKeyInput.Update(msg)
297− s.apiKeyInput = u.(*models.APIKeyInput)
429+ s.apiKeyInput = u.(*models.APIKeyInput)
298− return s, cmd
430+ return s, cmd
300 return s, nil
433 return s, nil
303−func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
436+func (s *splashCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
304 if s.selectedModel == nil {
437 if s.selectedModel == nil {
305 return nil
438 return nil
@@ -318,7 +451,10 @@ func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
318 s.selectedModel = nil
451 s.selectedModel = nil
319 s.isAPIKeyValid = false
452 s.isAPIKeyValid = false
321− return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
455+ return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
324func (s *splashCmp) initializeProject() tea.Cmd {
460func (s *splashCmp) initializeProject() tea.Cmd {
@@ -426,7 +562,39 @@ func (s *splashCmp) isProviderConfigured(providerID string) bool {
426func (s *splashCmp) View() string {
562func (s *splashCmp) View() string {
427 t := styles.CurrentTheme()
563 t := styles.CurrentTheme()
428 var content string
564 var content string
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(
571+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
576+ content = lipgloss.JoinVertical(
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(
587+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
592+ content = lipgloss.JoinVertical(
429− if s.needsAPIKey {
597+ } else if s.needsAPIKey {
430 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
598 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
431 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
599 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
432 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
600 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
@@ -524,6 +692,16 @@ func (s *splashCmp) View() string {
526func (s *splashCmp) Cursor() *tea.Cursor {
694func (s *splashCmp) Cursor() *tea.Cursor {
695+ if s.showClaudeAuthMethodChooser {
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)
527 if s.needsAPIKey {
705 if s.needsAPIKey {
528 cursor := s.apiKeyInput.Cursor()
706 cursor := s.apiKeyInput.Cursor()
529 if cursor != nil {
707 if cursor != nil {
@@ -596,17 +774,23 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
597 // Calculate the correct Y offset based on current state
775 // Calculate the correct Y offset based on current state
598 logoHeight := lipgloss.Height(s.logoRendered)
776 logoHeight := lipgloss.Height(s.logoRendered)
599− if s.needsAPIKey {
777+ if s.needsAPIKey || s.showClaudeOAuth2 {
780+ view = s.apiKeyInput.View()
782+ view = s.claudeOAuth2.View()
600 infoSectionHeight := lipgloss.Height(s.infoSection())
784 infoSectionHeight := lipgloss.Height(s.infoSection())
601 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
785 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
602− remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
786+ remainingHeight := s.height - baseOffset - lipgloss.Height(view) - SplashScreenPaddingY
603 offset := baseOffset + remainingHeight
787 offset := baseOffset + remainingHeight
604 cursor.Y += offset
788 cursor.Y += offset
605− cursor.X = cursor.X + 1
789+ cursor.X += 1
606 } else if s.isOnboarding {
790 } else if s.isOnboarding {
607 offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
791 offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
608 cursor.Y += offset
792 cursor.Y += offset
609− cursor.X = cursor.X + 1
793+ cursor.X += 1
612 return cursor
796 return cursor
@@ -621,7 +805,21 @@ func (s *splashCmp) logoGap() int {
622// Bindings implements SplashPage.
806// Bindings implements SplashPage.
623func (s *splashCmp) Bindings() []key.Binding {
807func (s *splashCmp) Bindings() []key.Binding {
808+ if s.showClaudeAuthMethodChooser {
809+ return []key.Binding{
814+ } else if s.showClaudeOAuth2 {
815+ bindings := []key.Binding{
818+ if s.claudeOAuth2.State == claude.OAuthStateURL {
819+ bindings = append(bindings, s.keyMap.Copy)
624− if s.needsAPIKey {
822+ } else if s.needsAPIKey {
625 return []key.Binding{
823 return []key.Binding{
626 s.keyMap.Select,
824 s.keyMap.Select,
627 s.keyMap.Back,
825 s.keyMap.Back,
@@ -726,3 +924,19 @@ func (s *splashCmp) IsShowingAPIKey() bool {
726func (s *splashCmp) IsAPIKeyValid() bool {
924func (s *splashCmp) IsAPIKeyValid() bool {
727 return s.isAPIKeyValid
925 return s.isAPIKeyValid
928+func (s *splashCmp) IsShowingClaudeAuthMethodChooser() bool {
929+ return s.showClaudeAuthMethodChooser
932+func (s *splashCmp) IsShowingClaudeOAuth2() bool {
933+ return s.showClaudeOAuth2
936+func (s *splashCmp) IsClaudeOAuthURLState() bool {
937+ return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL
940+func (s *splashCmp) IsClaudeOAuthComplete() bool {
941+ return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid