on main help sign in
feat: implement hyper oauth flow in the new ui codebase
c45b152Andrey Nering
internal/ui/dialog/actions.go+23
@@ -5,6 +5,7 @@ import (
5 "github.com/charmbracelet/catwalk/pkg/catwalk"
5 "github.com/charmbracelet/catwalk/pkg/catwalk"
6 "github.com/charmbracelet/crush/internal/commands"
6 "github.com/charmbracelet/crush/internal/commands"
7 "github.com/charmbracelet/crush/internal/config"
7 "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/oauth"
8 "github.com/charmbracelet/crush/internal/permission"
9 "github.com/charmbracelet/crush/internal/permission"
9 "github.com/charmbracelet/crush/internal/session"
10 "github.com/charmbracelet/crush/internal/session"
10)
11)
@@ -73,6 +74,28 @@ type (
73 }
74 }
74)
75)
75
76
77+// Messages for OAuth2 device flow dialog.
78+type (
79+ // ActionInitiateOAuth is sent when the device auth is initiated
80+ // successfully.
81+ ActionInitiateOAuth struct {
82+ DeviceCode string
83+ UserCode string
84+ ExpiresIn int
85+ VerificationURL string
86+ }
87+
88+ // ActionCompleteOAuth is sent when the device flow completes successfully.
89+ ActionCompleteOAuth struct {
90+ Token *oauth.Token
91+ }
92+
93+ // ActionOAuthErrored is sent when the device flow encounters an error.
94+ ActionOAuthErrored struct {
95+ Error error
96+ }
97+)
98+
76// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
99// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
77// Bubble Tea program loop.
100// Bubble Tea program loop.
78type ActionCmd struct {
101type ActionCmd struct {
internal/ui/dialog/oauth.gonew file+423
@@ -0,0 +1,423 @@
1+package dialog
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "time"
8+
9+ "charm.land/bubbles/v2/help"
10+ "charm.land/bubbles/v2/key"
11+ "charm.land/bubbles/v2/spinner"
12+ tea "charm.land/bubbletea/v2"
13+ "charm.land/lipgloss/v2"
14+ "github.com/charmbracelet/catwalk/pkg/catwalk"
15+ "github.com/charmbracelet/crush/internal/config"
16+ "github.com/charmbracelet/crush/internal/oauth"
17+ "github.com/charmbracelet/crush/internal/oauth/hyper"
18+ "github.com/charmbracelet/crush/internal/ui/common"
19+ "github.com/charmbracelet/crush/internal/uiutil"
20+ uv "github.com/charmbracelet/ultraviolet"
21+ "github.com/pkg/browser"
22+)
23+
24+// OAuthState represents the current state of the device flow.
25+type OAuthState int
26+
27+const (
28+ OAuthStateInitializing OAuthState = iota
29+ OAuthStateDisplay
30+ OAuthStateSuccess
31+ OAuthStateError
32+)
33+
34+// OAuthID is the identifier for the model selection dialog.
35+const OAuthID = "oauth"
36+
37+// OAuth handles the OAuth flow authentication.
38+type OAuth struct {
39+ com *common.Common
40+
41+ provider catwalk.Provider
42+ model config.SelectedModel
43+ modelType config.SelectedModelType
44+
45+ State OAuthState
46+
47+ spinner spinner.Model
48+ help help.Model
49+ keyMap struct {
50+ Copy key.Binding
51+ Submit key.Binding
52+ Close key.Binding
53+ }
54+
55+ width int
56+ deviceCode string
57+ userCode string
58+ verificationURL string
59+ expiresIn int
60+ token *oauth.Token
61+ cancelFunc context.CancelFunc
62+}
63+
64+var _ Dialog = (*OAuth)(nil)
65+
66+// NewOAuth creates a new device flow component.
67+func NewOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) {
68+ t := com.Styles
69+
70+ m := OAuth{}
71+ m.com = com
72+ m.provider = provider
73+ m.model = model
74+ m.modelType = modelType
75+ m.width = 60
76+ m.State = OAuthStateInitializing
77+
78+ m.spinner = spinner.New(
79+ spinner.WithSpinner(spinner.Dot),
80+ spinner.WithStyle(t.Base.Foreground(t.GreenLight)),
81+ )
82+
83+ m.help = help.New()
84+ m.help.Styles = t.DialogHelpStyles()
85+
86+ m.keyMap.Copy = key.NewBinding(
87+ key.WithKeys("c"),
88+ key.WithHelp("c", "copy code"),
89+ )
90+ m.keyMap.Submit = key.NewBinding(
91+ key.WithKeys("enter", "ctrl+y"),
92+ key.WithHelp("enter", "copy & open"),
93+ )
94+ m.keyMap.Close = CloseKey
95+
96+ return &m, nil
97+}
98+
99+// ID implements Dialog.
100+func (m *OAuth) ID() string {
101+ return OAuthID
102+}
103+
104+// Init implements Dialog.
105+func (m *OAuth) Init() tea.Cmd {
106+ return tea.Batch(m.spinner.Tick, m.initiateDeviceAuth)
107+}
108+
109+// HandleMsg handles messages and state transitions.
110+func (m *OAuth) HandleMsg(msg tea.Msg) Action {
111+ switch msg := msg.(type) {
112+ case spinner.TickMsg:
113+ switch m.State {
114+ case OAuthStateInitializing, OAuthStateDisplay:
115+ var cmd tea.Cmd
116+ m.spinner, cmd = m.spinner.Update(msg)
117+ if cmd != nil {
118+ return ActionCmd{cmd}
119+ }
120+ }
121+
122+ case tea.KeyPressMsg:
123+ switch {
124+ case key.Matches(msg, m.keyMap.Copy):
125+ cmd := m.copyCode()
126+ return ActionCmd{cmd}
127+
128+ case key.Matches(msg, m.keyMap.Submit):
129+ switch m.State {
130+ case OAuthStateSuccess:
131+ return m.saveKeyAndContinue()
132+
133+ default:
134+ cmd := m.copyCodeAndOpenURL()
135+ return ActionCmd{cmd}
136+ }
137+
138+ case key.Matches(msg, m.keyMap.Close):
139+ switch m.State {
140+ case OAuthStateSuccess:
141+ return m.saveKeyAndContinue()
142+
143+ default:
144+ return ActionClose{}
145+ }
146+ }
147+
148+ case ActionInitiateOAuth:
149+ m.deviceCode = msg.DeviceCode
150+ m.userCode = msg.UserCode
151+ m.expiresIn = msg.ExpiresIn
152+ m.verificationURL = msg.VerificationURL
153+ m.State = OAuthStateDisplay
154+ return ActionCmd{m.startPolling(msg.DeviceCode)}
155+
156+ case ActionCompleteOAuth:
157+ m.State = OAuthStateSuccess
158+ m.token = msg.Token
159+ return ActionCmd{m.stopPolling}
160+
161+ case ActionOAuthErrored:
162+ m.State = OAuthStateError
163+ cmd := tea.Batch(m.stopPolling, uiutil.ReportError(msg.Error))
164+ return ActionCmd{cmd}
165+ }
166+ return nil
167+}
168+
169+// View renders the device flow dialog.
170+func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
171+ var (
172+ t = m.com.Styles
173+ dialogStyle = t.Dialog.View.Width(m.width)
174+ view = dialogStyle.Render(m.dialogContent())
175+ )
176+ DrawCenterCursor(scr, area, view, nil)
177+ return nil
178+}
179+
180+func (m *OAuth) dialogContent() string {
181+ var (
182+ t = m.com.Styles
183+ helpStyle = t.Dialog.HelpView
184+ )
185+
186+ switch m.State {
187+ case OAuthStateInitializing:
188+ return m.innerDialogContent()
189+
190+ default:
191+ elements := []string{
192+ m.headerContent(),
193+ m.innerDialogContent(),
194+ helpStyle.Render(m.help.View(m)),
195+ }
196+ return strings.Join(elements, "\n")
197+ }
198+}
199+
200+func (m *OAuth) headerContent() string {
201+ var (
202+ t = m.com.Styles
203+ titleStyle = t.Dialog.Title
204+ dialogStyle = t.Dialog.View.Width(m.width)
205+ headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
206+ )
207+ return common.DialogTitle(t, titleStyle.Render("Authenticate with Hyper"), m.width-headerOffset)
208+}
209+
210+func (m *OAuth) innerDialogContent() string {
211+ var (
212+ t = m.com.Styles
213+ whiteStyle = lipgloss.NewStyle().Foreground(t.White)
214+ primaryStyle = lipgloss.NewStyle().Foreground(t.Primary)
215+ greenStyle = lipgloss.NewStyle().Foreground(t.GreenLight)
216+ linkStyle = lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
217+ errorStyle = lipgloss.NewStyle().Foreground(t.Error)
218+ mutedStyle = lipgloss.NewStyle().Foreground(t.FgMuted)
219+ )
220+
221+ switch m.State {
222+ case OAuthStateInitializing:
223+ return lipgloss.NewStyle().
224+ Margin(1, 1).
225+ Width(m.width - 2).
226+ Align(lipgloss.Center).
227+ Render(
228+ greenStyle.Render(m.spinner.View()) +
229+ mutedStyle.Render("Initializing..."),
230+ )
231+
232+ case OAuthStateDisplay:
233+ instructions := lipgloss.NewStyle().
234+ Margin(1).
235+ Width(m.width - 2).
236+ Render(
237+ whiteStyle.Render("Press ") +
238+ primaryStyle.Render("enter") +
239+ whiteStyle.Render(" to copy the code below and open the browser."),
240+ )
241+
242+ codeBox := lipgloss.NewStyle().
243+ Width(m.width-2).
244+ Height(7).
245+ Align(lipgloss.Center, lipgloss.Center).
246+ Background(t.BgBaseLighter).
247+ Margin(1).
248+ Render(
249+ lipgloss.NewStyle().
250+ Bold(true).
251+ Foreground(t.White).
252+ Render(m.userCode),
253+ )
254+
255+ link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL)
256+ url := mutedStyle.
257+ Margin(0, 1).
258+ Width(m.width - 2).
259+ Render("Browser not opening? Refer to\n" + link)
260+
261+ waiting := greenStyle.
262+ Width(m.width - 2).
263+ Margin(1).
264+ Render(m.spinner.View() + "Verifying...")
265+
266+ return lipgloss.JoinVertical(
267+ lipgloss.Left,
268+ instructions,
269+ codeBox,
270+ url,
271+ waiting,
272+ )
273+
274+ case OAuthStateSuccess:
275+ return greenStyle.
276+ Margin(1).
277+ Width(m.width - 2).
278+ Align(lipgloss.Center).
279+ Render("Authentication successful!")
280+
281+ case OAuthStateError:
282+ return lipgloss.NewStyle().
283+ Margin(1).
284+ Width(m.width - 2).
285+ Render(errorStyle.Render("Authentication failed."))
286+
287+ default:
288+ return ""
289+ }
290+}
291+
292+// FullHelp returns the full help view.
293+func (m *OAuth) FullHelp() [][]key.Binding {
294+ return [][]key.Binding{m.ShortHelp()}
295+}
296+
297+// ShortHelp returns the full help view.
298+func (m *OAuth) ShortHelp() []key.Binding {
299+ switch m.State {
300+ case OAuthStateError:
301+ return []key.Binding{m.keyMap.Close}
302+
303+ case OAuthStateSuccess:
304+ return []key.Binding{
305+ key.NewBinding(
306+ key.WithKeys("finish", "ctrl+y", "esc"),
307+ key.WithHelp("enter", "finish"),
308+ ),
309+ }
310+
311+ default:
312+ return []key.Binding{
313+ m.keyMap.Copy,
314+ m.keyMap.Submit,
315+ m.keyMap.Close,
316+ }
317+ }
318+}
319+
320+func (d *OAuth) copyCode() tea.Cmd {
321+ if d.State != OAuthStateDisplay {
322+ return nil
323+ }
324+ return tea.Sequence(
325+ tea.SetClipboard(d.userCode),
326+ uiutil.ReportInfo("Code copied to clipboard"),
327+ )
328+}
329+
330+func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
331+ if d.State != OAuthStateDisplay {
332+ return nil
333+ }
334+ return tea.Sequence(
335+ tea.SetClipboard(d.userCode),
336+ func() tea.Msg {
337+ if err := browser.OpenURL(d.verificationURL); err != nil {
338+ return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)}
339+ }
340+ return nil
341+ },
342+ uiutil.ReportInfo("Code copied and URL opened"),
343+ )
344+}
345+
346+func (m *OAuth) saveKeyAndContinue() Action {
347+ cfg := m.com.Config()
348+
349+ err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
350+ if err != nil {
351+ return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
352+ }
353+
354+ return ActionSelectModel{
355+ Provider: m.provider,
356+ Model: m.model,
357+ ModelType: m.modelType,
358+ }
359+}
360+
361+func (m *OAuth) initiateDeviceAuth() tea.Msg {
362+ minimumWait := 750 * time.Millisecond
363+ startTime := time.Now()
364+
365+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
366+ defer cancel()
367+
368+ authResp, err := hyper.InitiateDeviceAuth(ctx)
369+
370+ ellapsed := time.Since(startTime)
371+ if ellapsed < minimumWait {
372+ time.Sleep(minimumWait - ellapsed)
373+ }
374+
375+ if err != nil {
376+ return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)}
377+ }
378+
379+ return ActionInitiateOAuth{
380+ DeviceCode: authResp.DeviceCode,
381+ UserCode: authResp.UserCode,
382+ ExpiresIn: authResp.ExpiresIn,
383+ VerificationURL: authResp.VerificationURL,
384+ }
385+}
386+
387+// startPolling starts polling for the device token.
388+func (m *OAuth) startPolling(deviceCode string) tea.Cmd {
389+ return func() tea.Msg {
390+ ctx, cancel := context.WithCancel(context.Background())
391+ m.cancelFunc = cancel
392+
393+ refreshToken, err := hyper.PollForToken(ctx, deviceCode, m.expiresIn)
394+ if err != nil {
395+ if ctx.Err() != nil {
396+ return nil
397+ }
398+ return ActionOAuthErrored{err}
399+ }
400+
401+ token, err := hyper.ExchangeToken(ctx, refreshToken)
402+ if err != nil {
403+ return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)}
404+ }
405+
406+ introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
407+ if err != nil {
408+ return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)}
409+ }
410+ if !introspect.Active {
411+ return ActionOAuthErrored{fmt.Errorf("access token is not active")}
412+ }
413+
414+ return ActionCompleteOAuth{token}
415+ }
416+}
417+
418+func (m *OAuth) stopPolling() tea.Msg {
419+ if m.cancelFunc != nil {
420+ m.cancelFunc()
421+ }
422+ return nil
423+}
internal/ui/model/ui.go+28−2
@@ -934,7 +934,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
934 _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider)
934 _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider)
935 if !isProviderConfigured {
935 if !isProviderConfigured {
936 m.dialog.CloseDialog(dialog.ModelsID)
936 m.dialog.CloseDialog(dialog.ModelsID)
937− if cmd := m.openAPIKeyInputDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
937+ if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
938 cmds = append(cmds, cmd)
938 cmds = append(cmds, cmd)
939 }
939 }
940 break
940 break
@@ -950,6 +950,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
950 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
950 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
951 cmds = append(cmds, uiutil.ReportInfo(modelMsg))
951 cmds = append(cmds, uiutil.ReportInfo(modelMsg))
952 m.dialog.CloseDialog(dialog.APIKeyInputID)
952 m.dialog.CloseDialog(dialog.APIKeyInputID)
953+ m.dialog.CloseDialog(dialog.OAuthID)
953 m.dialog.CloseDialog(dialog.ModelsID)
954 m.dialog.CloseDialog(dialog.ModelsID)
954 // TODO CHANGE
955 // TODO CHANGE
955 case dialog.ActionPermissionResponse:
956 case dialog.ActionPermissionResponse:
@@ -1016,7 +1017,17 @@ func substituteArgs(content string, args map[string]string) string {
1016 return content
1017 return content
1017}
1018}
1018
1019
1020+func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1021+ switch provider.ID {
1022+ case "hyper":
1023+ return m.openOAuthDialog(provider, model, modelType)
1024+ case catwalk.InferenceProviderCopilot:
1025+ return m.openOAuthDialog(provider, model, modelType)
1026+ default:
1019−// openAPIKeyInputDialog opens the API key input dialog.
1027+ return m.openAPIKeyInputDialog(provider, model, modelType)
1028+ }
1029+}
1030+
1020func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1031func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1021 if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
1032 if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
1022 m.dialog.BringToFront(dialog.APIKeyInputID)
1033 m.dialog.BringToFront(dialog.APIKeyInputID)
@@ -1031,6 +1042,21 @@ func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.Selec
1031 return nil
1042 return nil
1032}
1043}
1033
1044
1045+func (m *UI) openOAuthDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1046+ if m.dialog.ContainsDialog(dialog.OAuthID) {
1047+ m.dialog.BringToFront(dialog.OAuthID)
1048+ return nil
1049+ }
1050+
1051+ oAuthDialog, err := dialog.NewOAuth(m.com, provider, model, modelType)
1052+ if err != nil {
1053+ return uiutil.ReportError(err)
1054+ }
1055+ m.dialog.OpenDialog(oAuthDialog)
1056+
1057+ return oAuthDialog.Init()
1058+}
1059+
1034func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1060func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1035 var cmds []tea.Cmd
1061 var cmds []tea.Cmd
1036
1062
internal/ui/styles/styles.go+8−2
@@ -173,12 +173,14 @@ type Styles struct {
173 FgSubtle color.Color
173 FgSubtle color.Color
174 Border color.Color
174 Border color.Color
175 BorderColor color.Color // Border focus color
175 BorderColor color.Color // Border focus color
176+ Error color.Color
176 Warning color.Color
177 Warning color.Color
177 Info color.Color
178 Info color.Color
178 White color.Color
179 White color.Color
179 BlueLight color.Color
180 BlueLight color.Color
180 Blue color.Color
181 Blue color.Color
181 BlueDark color.Color
182 BlueDark color.Color
183+ GreenLight color.Color
182 Green color.Color
184 Green color.Color
183 GreenDark color.Color
185 GreenDark color.Color
184 Red color.Color
186 Red color.Color
@@ -459,6 +461,7 @@ func DefaultStyles() Styles {
459 borderFocus = charmtone.Charple
461 borderFocus = charmtone.Charple
460
462
461 // Status
463 // Status
464+ error = charmtone.Sriracha
462 warning = charmtone.Zest
465 warning = charmtone.Zest
463 info = charmtone.Malibu
466 info = charmtone.Malibu
464
467
@@ -473,8 +476,9 @@ func DefaultStyles() Styles {
473 yellow = charmtone.Mustard
476 yellow = charmtone.Mustard
474 // citron = charmtone.Citron
477 // citron = charmtone.Citron
475
478
479+ greenLight = charmtone.Bok
476− green = charmtone.Julep
480+ green = charmtone.Julep
477− greenDark = charmtone.Guac
481+ greenDark = charmtone.Guac
478 // greenLight = charmtone.Bok
482 // greenLight = charmtone.Bok
479
483
480 red = charmtone.Coral
484 red = charmtone.Coral
@@ -505,12 +509,14 @@ func DefaultStyles() Styles {
505 s.FgSubtle = fgSubtle
509 s.FgSubtle = fgSubtle
506 s.Border = border
510 s.Border = border
507 s.BorderColor = borderFocus
511 s.BorderColor = borderFocus
512+ s.Error = error
508 s.Warning = warning
513 s.Warning = warning
509 s.Info = info
514 s.Info = info
510 s.White = white
515 s.White = white
511 s.BlueLight = blueLight
516 s.BlueLight = blueLight
512 s.Blue = blue
517 s.Blue = blue
513 s.BlueDark = blueDark
518 s.BlueDark = blueDark
519+ s.GreenLight = greenLight
514 s.Green = green
520 s.Green = green
515 s.GreenDark = greenDark
521 s.GreenDark = greenDark
516 s.Red = red
522 s.Red = red