Index: internal/ui/dialog/oauth_claude.go --- internal/ui/dialog/oauth_claude.go.orig +++ internal/ui/dialog/oauth_claude.go @@ -0,0 +1,241 @@ +package dialog + +import ( + "context" + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/oauth/claude" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/util" + uv "github.com/charmbracelet/ultraviolet" + "github.com/pkg/browser" +) + +type oauthClaudeState int + +const ( + oauthClaudeStateInput oauthClaudeState = iota + oauthClaudeStateExchanging + oauthClaudeStateError +) + +type oauthClaudeComplete struct{ token *oauth.Token } +type oauthClaudeError struct{ err error } + +// OAuthClaudeID is the identifier for the Claude OAuth dialog. +const OAuthClaudeID = "oauth_claude" + +// OAuthClaude is a dialog for Claude Code Max PKCE authentication. +type OAuthClaude struct { + com *common.Common + isOnboarding bool + provider catwalk.Provider + model config.SelectedModel + modelType config.SelectedModelType + + width int + state oauthClaudeState + verifier string + verificationURL string + + keyMap struct { + Submit key.Binding + Open key.Binding + Close key.Binding + } + input textinput.Model + spinner spinner.Model + help help.Model +} + +var _ Dialog = (*OAuthClaude)(nil) + +// NewOAuthClaude creates a new Claude PKCE authentication dialog. +func NewOAuthClaude( + com *common.Common, + isOnboarding bool, + provider catwalk.Provider, + model config.SelectedModel, + modelType config.SelectedModelType, +) (*OAuthClaude, tea.Cmd) { + verifier, challenge, err := claude.GetChallenge() + if err != nil { + m := &OAuthClaude{com: com, isOnboarding: isOnboarding, provider: provider, model: model, modelType: modelType, width: 60, state: oauthClaudeStateError} + return m, util.ReportError(fmt.Errorf("failed to generate PKCE challenge: %w", err)) + } + authURL, err := claude.AuthorizeURL(verifier, challenge) + if err != nil { + m := &OAuthClaude{com: com, isOnboarding: isOnboarding, provider: provider, model: model, modelType: modelType, width: 60, state: oauthClaudeStateError} + return m, util.ReportError(fmt.Errorf("failed to build auth URL: %w", err)) + } + + t := com.Styles + m := &OAuthClaude{ + com: com, isOnboarding: isOnboarding, provider: provider, + model: model, modelType: modelType, width: 60, + state: oauthClaudeStateInput, verifier: verifier, verificationURL: authURL, + } + + innerWidth := m.width - t.Dialog.View.GetHorizontalFrameSize() - 2 + m.input = textinput.New() + m.input.SetVirtualCursor(false) + m.input.Placeholder = "Paste authorization code..." + m.input.SetStyles(com.Styles.TextInput) + m.input.Focus() + m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) + m.input.Prompt = "> " + + m.spinner = spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(t.Dialog.APIKey.Spinner), + ) + m.help = help.New() + m.help.Styles = t.DialogHelpStyles() + + m.keyMap.Submit = key.NewBinding(key.WithKeys("enter", "ctrl+y"), key.WithHelp("enter", "submit")) + m.keyMap.Open = key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open browser")) + m.keyMap.Close = CloseKey + + return m, nil +} + +func (m *OAuthClaude) ID() string { return OAuthClaudeID } + +func (m *OAuthClaude) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case oauthClaudeComplete: + err := m.com.Workspace.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), msg.token) + if err != nil { + return ActionCmd{util.ReportError(fmt.Errorf("failed to save token: %w", err))} + } + return ActionSelectModel{Provider: m.provider, Model: m.model, ModelType: m.modelType} + + case oauthClaudeError: + m.state = oauthClaudeStateError + return ActionCmd{util.ReportError(msg.err)} + + case spinner.TickMsg: + if m.state == oauthClaudeStateExchanging { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + + case tea.KeyPressMsg: + switch { + case m.state == oauthClaudeStateExchanging: + // absorb input during exchange + case key.Matches(msg, m.keyMap.Close): + return ActionClose{} + case key.Matches(msg, m.keyMap.Open) && m.state == oauthClaudeStateInput: + _ = browser.OpenURL(m.verificationURL) + case key.Matches(msg, m.keyMap.Submit) && m.state == oauthClaudeStateInput: + code := strings.TrimSpace(m.input.Value()) + if code == "" { + return nil + } + m.state = oauthClaudeStateExchanging + verifier := m.verifier + return ActionCmd{tea.Batch(m.spinner.Tick, func() tea.Msg { + token, err := claude.ExchangeToken(context.Background(), code, verifier) + if err != nil { + return oauthClaudeError{err} + } + return oauthClaudeComplete{token} + })} + default: + if m.state == oauthClaudeStateInput { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + } + + case tea.PasteMsg: + if m.state == oauthClaudeStateInput { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + } + return nil +} + +func (m *OAuthClaude) Cursor() *tea.Cursor { + return InputCursor(m.com.Styles, m.input.Cursor()) +} + +func (m *OAuthClaude) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := m.com.Styles + dialogStyle := t.Dialog.View.Width(m.width) + helpStyle := t.Dialog.HelpView.Width(m.width - dialogStyle.GetHorizontalFrameSize()) + textStyle := t.Dialog.SecondaryText + inputStyle := t.Dialog.InputPrompt + linkStyle := t.Dialog.OAuth.Link + + if m.state == oauthClaudeStateExchanging { + m.input.Prompt = m.spinner.View() + } else { + m.input.Prompt = "> " + } + + content := strings.Join([]string{ + m.headerView(), + textStyle.Render("Visit the URL and paste the authorization code:"), + "", + textStyle.Render(linkStyle.Hyperlink(m.verificationURL, "id=oauth-claude").Render(m.verificationURL)), + "", + inputStyle.Render(m.input.View()), + "", + helpStyle.Render(m.help.View(m)), + }, "\n") + + cur := m.Cursor() + if m.isOnboarding { + cur = adjustOnboardingInputCursor(t, cur) + DrawOnboardingCursor(scr, area, content, cur) + } else { + view := dialogStyle.Render(content) + DrawCenterCursor(scr, area, view, cur) + } + return cur +} + +func (m *OAuthClaude) headerView() string { + t := m.com.Styles + titleStyle := t.Dialog.Title + textStyle := t.Dialog.PrimaryText + dialogStyle := t.Dialog.View.Width(m.width) + title := fmt.Sprintf("Authenticate with %s", m.provider.Name) + if m.isOnboarding { + return textStyle.Render(title) + } + headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() + return common.DialogTitle(t, titleStyle.Render(title), m.width-headerOffset, t.Dialog.TitleGradFromColor, t.Dialog.TitleGradToColor) +} + +func (m *OAuthClaude) ShortHelp() []key.Binding { + if m.state == oauthClaudeStateInput { + return []key.Binding{m.keyMap.Open, m.keyMap.Submit, m.keyMap.Close} + } + return []key.Binding{m.keyMap.Close} +} + +func (m *OAuthClaude) FullHelp() [][]key.Binding { + return [][]key.Binding{m.ShortHelp()} +}