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