feat: add github copilot auth flow via tui

Andrey Nering created

Change summary

internal/cmd/login.go                                  |  13 
internal/config/config.go                              |   8 
internal/oauth/copilot/oauth.go                        |  11 
internal/oauth/copilot/urls.go                         |   6 
internal/tui/components/dialogs/copilot/device_flow.go | 281 ++++++++++++
internal/tui/components/dialogs/models/keys.go         |  15 
internal/tui/components/dialogs/models/list.go         |   3 
internal/tui/components/dialogs/models/models.go       |  72 ++
8 files changed, 394 insertions(+), 15 deletions(-)

Detailed changes

internal/cmd/login.go 🔗

@@ -33,6 +33,9 @@ crush login
 
 # Authenticate with Claude Code Max
 crush login claude
+
+# Authenticate with GitHub Copilot
+crush login copilot
   `,
 	ValidArgs: []cobra.Completion{
 		"hyper",
@@ -223,6 +226,16 @@ func loginCopilot() error {
 		fmt.Println("Waiting for authorization...")
 
 		t, err := copilot.PollForToken(ctx, dc)
+		if err == copilot.ErrNotAvailable {
+			fmt.Println()
+			fmt.Println("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
+			fmt.Println()
+			fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL))
+			fmt.Println()
+			fmt.Println("You may be able to request free access if elegible. For more information, see:")
+			fmt.Println()
+			fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL))
+		}
 		if err != nil {
 			return err
 		}

internal/config/config.go 🔗

@@ -558,10 +558,14 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
 			return err
 		}
 		setKeyOrToken = func() {
-			providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken)
+			providerConfig.APIKey = v.AccessToken
 			providerConfig.OAuthToken = v
-			if providerID == string(catwalk.InferenceProviderAnthropic) {
+			switch providerID {
+			case string(catwalk.InferenceProviderAnthropic):
+				providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken)
 				providerConfig.SetupClaudeCode()
+			case string(catwalk.InferenceProviderCopilot):
+				providerConfig.SetupGitHubCopilot()
 			}
 		}
 	}

internal/oauth/copilot/oauth.go 🔗

@@ -3,6 +3,7 @@ package copilot
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -21,6 +22,8 @@ const (
 	copilotTokenURL = "https://api.github.com/copilot_internal/v2/token"
 )
 
+var ErrNotAvailable = errors.New("github copilot not available")
+
 type DeviceCode struct {
 	DeviceCode      string `json:"device_code"`
 	UserCode        string `json:"user_code"`
@@ -166,12 +169,10 @@ func getCopilotToken(ctx context.Context, githubToken string) (*oauth.Token, err
 		return nil, err
 	}
 
+	if resp.StatusCode == http.StatusForbidden {
+		return nil, ErrNotAvailable
+	}
 	if resp.StatusCode != http.StatusOK {
-		if resp.StatusCode == http.StatusNotFound {
-			return nil, fmt.Errorf("copilot not available for this account\n\n" +
-				"Please ensure you have GitHub Copilot enabled at:\n" +
-				"https://github.com/settings/copilot")
-		}
 		return nil, fmt.Errorf("copilot token request failed: %s - %s", resp.Status, string(body))
 	}
 

internal/oauth/copilot/urls.go 🔗

@@ -0,0 +1,6 @@
+package copilot
+
+const (
+	SignupURL = "https://github.com/github-copilot/signup?editor=crush"
+	FreeURL   = "https://docs.github.com/en/copilot/how-tos/manage-your-account/get-free-access-to-copilot-pro"
+)

internal/tui/components/dialogs/copilot/device_flow.go 🔗

@@ -0,0 +1,281 @@
+// Package copilot provides the dialog for Copilot device flow authentication.
+package copilot
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"charm.land/bubbles/v2/spinner"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/oauth"
+	"github.com/charmbracelet/crush/internal/oauth/copilot"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/pkg/browser"
+)
+
+// DeviceFlowState represents the current state of the device flow.
+type DeviceFlowState int
+
+const (
+	DeviceFlowStateDisplay DeviceFlowState = iota
+	DeviceFlowStateSuccess
+	DeviceFlowStateError
+	DeviceFlowStateUnavailable
+)
+
+// DeviceAuthInitiatedMsg is sent when the device auth is initiated
+// successfully.
+type DeviceAuthInitiatedMsg struct {
+	deviceCode *copilot.DeviceCode
+}
+
+// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
+type DeviceFlowCompletedMsg struct {
+	Token *oauth.Token
+}
+
+// DeviceFlowErrorMsg is sent when the device flow encounters an error.
+type DeviceFlowErrorMsg struct {
+	Error error
+}
+
+// DeviceFlow handles the Copilot device flow authentication.
+type DeviceFlow struct {
+	State      DeviceFlowState
+	width      int
+	deviceCode *copilot.DeviceCode
+	token      *oauth.Token
+	cancelFunc context.CancelFunc
+	spinner    spinner.Model
+}
+
+// NewDeviceFlow creates a new device flow component.
+func NewDeviceFlow() *DeviceFlow {
+	s := spinner.New()
+	s.Spinner = spinner.Dot
+	s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
+	return &DeviceFlow{
+		State:   DeviceFlowStateDisplay,
+		spinner: s,
+	}
+}
+
+// Init initializes the device flow by calling the device auth API and starting polling.
+func (d *DeviceFlow) Init() tea.Cmd {
+	return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
+}
+
+// Update handles messages and state transitions.
+func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	d.spinner, cmd = d.spinner.Update(msg)
+
+	switch msg := msg.(type) {
+	case DeviceAuthInitiatedMsg:
+		return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
+	case DeviceFlowCompletedMsg:
+		d.State = DeviceFlowStateSuccess
+		d.token = msg.Token
+		return d, nil
+	case DeviceFlowErrorMsg:
+		switch msg.Error {
+		case copilot.ErrNotAvailable:
+			d.State = DeviceFlowStateUnavailable
+		default:
+			d.State = DeviceFlowStateError
+		}
+		return d, nil
+	}
+
+	return d, cmd
+}
+
+// View renders the device flow dialog.
+func (d *DeviceFlow) View() string {
+	t := styles.CurrentTheme()
+
+	whiteStyle := lipgloss.NewStyle().Foreground(t.White)
+	primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
+	greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
+	linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
+	errorStyle := lipgloss.NewStyle().Foreground(t.Error)
+	mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
+
+	switch d.State {
+	case DeviceFlowStateDisplay:
+		if d.deviceCode == nil {
+			return lipgloss.NewStyle().
+				Margin(0, 1).
+				Render(
+					greenStyle.Render(d.spinner.View()) +
+						mutedStyle.Render("Initializing..."),
+				)
+		}
+
+		instructions := lipgloss.NewStyle().
+			Margin(1, 1, 0, 1).
+			Width(d.width - 2).
+			Render(
+				whiteStyle.Render("Press ") +
+					primaryStyle.Render("enter") +
+					whiteStyle.Render(" to copy the code below and open the browser."),
+			)
+
+		codeBox := lipgloss.NewStyle().
+			Width(d.width-2).
+			Height(7).
+			Align(lipgloss.Center, lipgloss.Center).
+			Background(t.BgBaseLighter).
+			Margin(1).
+			Render(
+				lipgloss.NewStyle().
+					Bold(true).
+					Foreground(t.White).
+					Render(d.deviceCode.UserCode),
+			)
+
+		uri := d.deviceCode.VerificationURI
+		link := lipgloss.NewStyle().Hyperlink(uri, "id=copilot-verify").Render(uri)
+		url := mutedStyle.
+			Margin(0, 1).
+			Width(d.width - 2).
+			Render("Browser not opening? Refer to\n" + link)
+
+		waiting := greenStyle.
+			Width(d.width-2).
+			Margin(1, 1, 0, 1).
+			Render(d.spinner.View() + "Verifying...")
+
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			instructions,
+			codeBox,
+			url,
+			waiting,
+		)
+
+	case DeviceFlowStateSuccess:
+		return greenStyle.Margin(0, 1).Render("Authentication successful!")
+
+	case DeviceFlowStateError:
+		return lipgloss.NewStyle().
+			Margin(0, 1).
+			Width(d.width - 2).
+			Render(errorStyle.Render("Authentication failed."))
+
+	case DeviceFlowStateUnavailable:
+		message := lipgloss.NewStyle().
+			Margin(0, 1).
+			Width(d.width - 2).
+			Render("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
+		freeMessage := lipgloss.NewStyle().
+			Margin(0, 1).
+			Width(d.width - 2).
+			Render("You may be able to request free access if elegible. For more information, see:")
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			message,
+			"",
+			linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL),
+			"",
+			freeMessage,
+			"",
+			linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL),
+		)
+
+	default:
+		return ""
+	}
+}
+
+// SetWidth sets the width of the dialog.
+func (d *DeviceFlow) SetWidth(w int) {
+	d.width = w
+}
+
+// Cursor hides the cursor.
+func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
+
+// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
+func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
+	switch d.State {
+	case DeviceFlowStateDisplay:
+		return tea.Sequence(
+			tea.SetClipboard(d.deviceCode.UserCode),
+			func() tea.Msg {
+				if err := browser.OpenURL(d.deviceCode.VerificationURI); err != nil {
+					return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
+				}
+				return nil
+			},
+			util.ReportInfo("Code copied and URL opened"),
+		)
+	case DeviceFlowStateUnavailable:
+		return tea.Sequence(
+			func() tea.Msg {
+				if err := browser.OpenURL(copilot.SignupURL); err != nil {
+					return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
+				}
+				return nil
+			},
+			util.ReportInfo("Code copied and URL opened"),
+		)
+	default:
+		return nil
+	}
+}
+
+// CopyCode copies just the user code to the clipboard.
+func (d *DeviceFlow) CopyCode() tea.Cmd {
+	if d.State != DeviceFlowStateDisplay {
+		return nil
+	}
+	return tea.Sequence(
+		tea.SetClipboard(d.deviceCode.UserCode),
+		util.ReportInfo("Code copied to clipboard"),
+	)
+}
+
+// Cancel cancels the device flow polling.
+func (d *DeviceFlow) Cancel() {
+	if d.cancelFunc != nil {
+		d.cancelFunc()
+	}
+}
+
+func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	deviceCode, err := copilot.RequestDeviceCode(ctx)
+	if err != nil {
+		return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
+	}
+
+	d.deviceCode = deviceCode
+
+	return DeviceAuthInitiatedMsg{
+		deviceCode: d.deviceCode,
+	}
+}
+
+// startPolling starts polling for the device token.
+func (d *DeviceFlow) startPolling(deviceCode *copilot.DeviceCode) tea.Cmd {
+	return func() tea.Msg {
+		ctx, cancel := context.WithCancel(context.Background())
+		d.cancelFunc = cancel
+
+		token, err := copilot.PollForToken(ctx, deviceCode)
+		if err != nil {
+			if ctx.Err() != nil {
+				return nil // cancelled, don't report error.
+			}
+			return DeviceFlowErrorMsg{Error: err}
+		}
+
+		return DeviceFlowCompletedMsg{Token: token}
+	}
+}

internal/tui/components/dialogs/models/keys.go 🔗

@@ -15,7 +15,9 @@ type KeyMap struct {
 	isAPIKeyHelp  bool
 	isAPIKeyValid bool
 
-	isHyperDeviceFlow bool
+	isHyperDeviceFlow    bool
+	isCopilotDeviceFlow  bool
+	isCopilotUnavailable bool
 
 	isClaudeAuthChoiceHelp    bool
 	isClaudeOAuthHelp         bool
@@ -76,7 +78,7 @@ func (k KeyMap) FullHelp() [][]key.Binding {
 
 // ShortHelp implements help.KeyMap.
 func (k KeyMap) ShortHelp() []key.Binding {
-	if k.isHyperDeviceFlow {
+	if k.isHyperDeviceFlow || k.isCopilotDeviceFlow {
 		return []key.Binding{
 			key.NewBinding(
 				key.WithKeys("c"),
@@ -89,6 +91,15 @@ func (k KeyMap) ShortHelp() []key.Binding {
 			k.Close,
 		}
 	}
+	if k.isCopilotUnavailable {
+		return []key.Binding{
+			key.NewBinding(
+				key.WithKeys("enter"),
+				key.WithHelp("enter", "open signup"),
+			),
+			k.Close,
+		}
+	}
 	if k.isClaudeAuthChoiceHelp {
 		return []key.Binding{
 			key.NewBinding(

internal/tui/components/dialogs/models/list.go 🔗

@@ -63,7 +63,8 @@ func (m *ModelListComponent) Init() tea.Cmd {
 		for _, p := range providers {
 			hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
 			isHyper := p.ID == "hyper"
-			if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper {
+			isCopilot := p.ID == catwalk.InferenceProviderCopilot
+			if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper || isCopilot {
 				filteredProviders = append(filteredProviders, p)
 			}
 		}

internal/tui/components/dialogs/models/models.go 🔗

@@ -17,6 +17,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
 	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
@@ -77,6 +78,10 @@ type modelDialogCmp struct {
 	hyperDeviceFlow     *hyper.DeviceFlow
 	showHyperDeviceFlow bool
 
+	// Copilot device flow state
+	copilotDeviceFlow     *copilot.DeviceFlow
+	showCopilotDeviceFlow bool
+
 	// Claude state
 	claudeAuthMethodChooser     *claude.AuthMethodChooser
 	claudeOAuth2                *claude.OAuth2
@@ -143,6 +148,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			return m, cmd
 		}
 		return m, nil
+	case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
+		if m.copilotDeviceFlow != nil {
+			u, cmd := m.copilotDeviceFlow.Update(msg)
+			m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
+			return m, cmd
+		}
+		return m, nil
+	case copilot.DeviceFlowCompletedMsg:
+		return m, m.saveOauthTokenAndContinue(msg.Token, true)
 	case claude.ValidationCompletedMsg:
 		var cmds []tea.Cmd
 		u, cmd := m.claudeOAuth2.Update(msg)
@@ -160,10 +174,13 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	case tea.KeyPressMsg:
 		switch {
 		// Handle Hyper device flow keys
-		case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow:
+		case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && (m.showHyperDeviceFlow || m.showCopilotDeviceFlow):
 			if m.hyperDeviceFlow != nil {
 				return m, m.hyperDeviceFlow.CopyCode()
 			}
+			if m.copilotDeviceFlow != nil {
+				return m, m.copilotDeviceFlow.CopyCode()
+			}
 		case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
 			return m, tea.Sequence(
 				tea.SetClipboard(m.claudeOAuth2.URL),
@@ -181,6 +198,9 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
 				return m, m.hyperDeviceFlow.CopyCodeAndOpenURL()
 			}
+			if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
+				return m, m.copilotDeviceFlow.CopyCodeAndOpenURL()
+			}
 			selectedItem := m.modelList.SelectedModel()
 
 			modelType := config.SelectedModelTypeLarge
@@ -193,6 +213,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				m.keyMap.isClaudeOAuthHelp = false
 				m.keyMap.isAPIKeyHelp = true
 				m.showHyperDeviceFlow = false
+				m.showCopilotDeviceFlow = false
 				m.showClaudeAuthMethodChooser = false
 				m.needsAPIKey = true
 				m.selectedModel = selectedItem
@@ -288,6 +309,13 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				m.hyperDeviceFlow = hyper.NewDeviceFlow()
 				m.hyperDeviceFlow.SetWidth(m.width - 2)
 				return m, m.hyperDeviceFlow.Init()
+			case catwalk.InferenceProviderCopilot:
+				m.showCopilotDeviceFlow = true
+				m.selectedModel = selectedItem
+				m.selectedModelType = modelType
+				m.copilotDeviceFlow = copilot.NewDeviceFlow()
+				m.copilotDeviceFlow.SetWidth(m.width - 2)
+				return m, m.copilotDeviceFlow.Init()
 			}
 			// For other providers, show API key input
 			askForApiKey()
@@ -310,13 +338,19 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			}
 		case key.Matches(msg, m.keyMap.Close):
 			if m.showHyperDeviceFlow {
-				// Cancel device flow and go back to model selection
 				if m.hyperDeviceFlow != nil {
 					m.hyperDeviceFlow.Cancel()
 				}
 				m.showHyperDeviceFlow = false
 				m.selectedModel = nil
 			}
+			if m.showCopilotDeviceFlow {
+				if m.copilotDeviceFlow != nil {
+					m.copilotDeviceFlow.Cancel()
+				}
+				m.showCopilotDeviceFlow = false
+				m.selectedModel = nil
+			}
 			if m.showClaudeAuthMethodChooser {
 				m.claudeAuthMethodChooser.SetDefaults()
 				m.showClaudeAuthMethodChooser = false
@@ -377,18 +411,27 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			u, cmd = m.hyperDeviceFlow.Update(msg)
 			m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
 		}
+		if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
+			u, cmd = m.copilotDeviceFlow.Update(msg)
+			m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
+		}
 		return m, cmd
 	default:
 		// Pass all other messages to the device flow for spinner animation
-		if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
+		switch {
+		case m.showHyperDeviceFlow && m.hyperDeviceFlow != nil:
 			u, cmd := m.hyperDeviceFlow.Update(msg)
 			m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
 			return m, cmd
-		} else if m.showClaudeOAuth2 {
+		case m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil:
+			u, cmd := m.copilotDeviceFlow.Update(msg)
+			m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
+			return m, cmd
+		case m.showClaudeOAuth2:
 			u, cmd := m.claudeOAuth2.Update(msg)
 			m.claudeOAuth2 = u.(*claude.OAuth2)
 			return m, cmd
-		} else {
+		default:
 			u, cmd := m.apiKeyInput.Update(msg)
 			m.apiKeyInput = u.(*APIKeyInput)
 			return m, cmd
@@ -413,9 +456,25 @@ func (m *modelDialogCmp) View() string {
 		)
 		return m.style().Render(content)
 	}
+	if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
+		// Show Hyper device flow
+		m.keyMap.isCopilotDeviceFlow = m.copilotDeviceFlow.State != copilot.DeviceFlowStateUnavailable
+		m.keyMap.isCopilotUnavailable = m.copilotDeviceFlow.State == copilot.DeviceFlowStateUnavailable
+		deviceFlowView := m.copilotDeviceFlow.View()
+		content := lipgloss.JoinVertical(
+			lipgloss.Left,
+			t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with GitHub Copilot", m.width-4)),
+			deviceFlowView,
+			"",
+			t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
+		)
+		return m.style().Render(content)
+	}
 
 	// Reset the flags when not showing device flow
 	m.keyMap.isHyperDeviceFlow = false
+	m.keyMap.isCopilotDeviceFlow = false
+	m.keyMap.isCopilotUnavailable = false
 
 	switch {
 	case m.showClaudeAuthMethodChooser:
@@ -472,6 +531,9 @@ func (m *modelDialogCmp) Cursor() *tea.Cursor {
 	if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
 		return m.hyperDeviceFlow.Cursor()
 	}
+	if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
+		return m.copilotDeviceFlow.Cursor()
+	}
 	if m.showClaudeAuthMethodChooser {
 		return nil
 	}