Files
mystuff/devel/crush/patches/patch-internal_ui_dialog_oauth_claude_go
2026-07-08 18:38:17 +02:00

246 lines
7.3 KiB
Plaintext

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()}
+}