From f2f44540f58e3cca3602f11d5bd7c002e3ff3109 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 14:23:45 -0300 Subject: [PATCH] feat: implement github copilot oauth flow in the new ui codebase --- 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(-) create mode 100644 internal/ui/dialog/oauth_copilot.go diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 0048c4ac06540e22175d4663e1ee9123e1eba211..1c7e9c2cdd9338cac4f28aee0d87ec7c08f5fa15 100644 --- a/internal/ui/dialog/actions.go +++ b/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. diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index f87a583be4160edf1d6a1e42de29f7dd01a513bc..1268d8a2324fff56843e8468cb2d4d3cd66895af 100644 --- a/internal/ui/dialog/oauth.go +++ b/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)} diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go new file mode 100644 index 0000000000000000000000000000000000000000..2364c02804ffc0eca776b24849bdb38aacf1df94 --- /dev/null +++ b/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 +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e9bd2fd3cedb5dd93bb5e1bfe31e2fb2b6f65f72..a79598bd857688491b57c58d85244e2499408498 100644 --- a/internal/ui/model/ui.go +++ b/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 {