feat: implement github copilot oauth flow in the new ui codebase

Andrey Nering created

Change summary

internal/ui/dialog/actions.go       |  1 
internal/ui/dialog/oauth.go         |  2 
internal/ui/dialog/oauth_copilot.go | 72 ++++++++++++++++++++++++++++++
internal/ui/model/ui.go             | 27 ++++++++++-
4 files changed, 99 insertions(+), 3 deletions(-)

Detailed changes

internal/ui/dialog/actions.go 🔗

@@ -83,6 +83,7 @@ type (
 		UserCode        string
 		ExpiresIn       int
 		VerificationURL string
+		Interval        int
 	}
 
 	// ActionCompleteOAuth is sent when the device flow completes successfully.

internal/ui/dialog/oauth.go 🔗

@@ -63,6 +63,7 @@ type OAuth struct {
 	userCode        string
 	verificationURL string
 	expiresIn       int
+	interval        int
 	token           *oauth.Token
 	cancelFunc      context.CancelFunc
 }
@@ -157,6 +158,7 @@ func (m *OAuth) HandleMsg(msg tea.Msg) Action {
 		m.userCode = msg.UserCode
 		m.expiresIn = msg.ExpiresIn
 		m.verificationURL = msg.VerificationURL
+		m.interval = msg.Interval
 		m.State = OAuthStateDisplay
 		return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)}
 

internal/ui/dialog/oauth_copilot.go 🔗

@@ -0,0 +1,72 @@
+package dialog
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth/copilot"
+	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) {
+	return newOAuth(com, provider, model, modelType, &OAuthCopilot{})
+}
+
+type OAuthCopilot struct {
+	deviceCode *copilot.DeviceCode
+	cancelFunc func()
+}
+
+var _ OAuthProvider = (*OAuthCopilot)(nil)
+
+func (m *OAuthCopilot) name() string {
+	return "GitHub Copilot"
+}
+
+func (m *OAuthCopilot) initiateAuth() tea.Msg {
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	deviceCode, err := copilot.RequestDeviceCode(ctx)
+	if err != nil {
+		return ActionOAuthErrored{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
+	}
+
+	m.deviceCode = deviceCode
+
+	return ActionInitiateOAuth{
+		DeviceCode:      deviceCode.DeviceCode,
+		UserCode:        deviceCode.UserCode,
+		VerificationURL: deviceCode.VerificationURI,
+		ExpiresIn:       deviceCode.ExpiresIn,
+		Interval:        deviceCode.Interval,
+	}
+}
+
+func (m *OAuthCopilot) startPolling(deviceCode string, expiresIn int) tea.Cmd {
+	return func() tea.Msg {
+		ctx, cancel := context.WithCancel(context.Background())
+		m.cancelFunc = cancel
+
+		token, err := copilot.PollForToken(ctx, m.deviceCode)
+		if err != nil {
+			if ctx.Err() != nil {
+				return nil // cancelled, don't report error.
+			}
+			return ActionOAuthErrored{Error: err}
+		}
+
+		return ActionCompleteOAuth{Token: token}
+	}
+}
+
+func (m *OAuthCopilot) stopPolling() tea.Msg {
+	if m.cancelFunc != nil {
+		m.cancelFunc()
+	}
+	return nil
+}

internal/ui/model/ui.go 🔗

@@ -931,8 +931,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			break
 		}
 
-		_, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider)
-		if !isProviderConfigured {
+		var (
+			providerID   = msg.Model.Provider
+			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
+			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
+		)
+
+		// Attempt to import GitHub Copilot tokens from VSCode if available.
+		if isCopilot && !isConfigured() {
+			config.Get().ImportCopilot()
+		}
+
+		if !isConfigured() {
 			m.dialog.CloseDialog(dialog.ModelsID)
 			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
 				cmds = append(cmds, cmd)
@@ -1058,7 +1068,18 @@ func (m *UI) openOAuthHyperDialog(provider catwalk.Provider, model config.Select
 }
 
 func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
-	panic("TODO")
+	if m.dialog.ContainsDialog(dialog.OAuthID) {
+		m.dialog.BringToFront(dialog.OAuthID)
+		return nil
+	}
+
+	oAuthDialog, err := dialog.NewOAuthCopilot(m.com, provider, model, modelType)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+	m.dialog.OpenDialog(oAuthDialog)
+
+	return oAuthDialog.Init()
 }
 
 func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {