@@ -0,0 +1,423 @@
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"
24+// OAuthState represents the current state of the device flow.
28+ OAuthStateInitializing OAuthState = iota
34+// OAuthID is the identifier for the model selection dialog.
35+const OAuthID = "oauth"
37+// OAuth handles the OAuth flow authentication.
41+ provider catwalk.Provider
42+ model config.SelectedModel
43+ modelType config.SelectedModelType
47+ spinner spinner.Model
58+ verificationURL string
61+ cancelFunc context.CancelFunc
64+var _ Dialog = (*OAuth)(nil)
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) {
72+ m.provider = provider
74+ m.modelType = modelType
76+ m.State = OAuthStateInitializing
78+ m.spinner = spinner.New(
79+ spinner.WithSpinner(spinner.Dot),
80+ spinner.WithStyle(t.Base.Foreground(t.GreenLight)),
84+ m.help.Styles = t.DialogHelpStyles()
86+ m.keyMap.Copy = key.NewBinding(
88+ key.WithHelp("c", "copy code"),
90+ m.keyMap.Submit = key.NewBinding(
91+ key.WithKeys("enter", "ctrl+y"),
92+ key.WithHelp("enter", "copy & open"),
94+ m.keyMap.Close = CloseKey
99+// ID implements Dialog.
100+func (m *OAuth) ID() string {
104+// Init implements Dialog.
105+func (m *OAuth) Init() tea.Cmd {
106+ return tea.Batch(m.spinner.Tick, m.initiateDeviceAuth)
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:
114+ case OAuthStateInitializing, OAuthStateDisplay:
116+ m.spinner, cmd = m.spinner.Update(msg)
118+ return ActionCmd{cmd}
122+ case tea.KeyPressMsg:
124+ case key.Matches(msg, m.keyMap.Copy):
126+ return ActionCmd{cmd}
128+ case key.Matches(msg, m.keyMap.Submit):
130+ case OAuthStateSuccess:
131+ return m.saveKeyAndContinue()
134+ cmd := m.copyCodeAndOpenURL()
135+ return ActionCmd{cmd}
138+ case key.Matches(msg, m.keyMap.Close):
140+ case OAuthStateSuccess:
141+ return m.saveKeyAndContinue()
144+ return ActionClose{}
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)}
156+ case ActionCompleteOAuth:
157+ m.State = OAuthStateSuccess
159+ return ActionCmd{m.stopPolling}
161+ case ActionOAuthErrored:
162+ m.State = OAuthStateError
163+ cmd := tea.Batch(m.stopPolling, uiutil.ReportError(msg.Error))
164+ return ActionCmd{cmd}
169+// View renders the device flow dialog.
170+func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
173+ dialogStyle = t.Dialog.View.Width(m.width)
174+ view = dialogStyle.Render(m.dialogContent())
176+ DrawCenterCursor(scr, area, view, nil)
180+func (m *OAuth) dialogContent() string {
183+ helpStyle = t.Dialog.HelpView
187+ case OAuthStateInitializing:
188+ return m.innerDialogContent()
191+ elements := []string{
193+ m.innerDialogContent(),
194+ helpStyle.Render(m.help.View(m)),
196+ return strings.Join(elements, "\n")
200+func (m *OAuth) headerContent() string {
203+ titleStyle = t.Dialog.Title
204+ dialogStyle = t.Dialog.View.Width(m.width)
205+ headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
207+ return common.DialogTitle(t, titleStyle.Render("Authenticate with Hyper"), m.width-headerOffset)
210+func (m *OAuth) innerDialogContent() string {
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)
222+ case OAuthStateInitializing:
223+ return lipgloss.NewStyle().
226+ Align(lipgloss.Center).
228+ greenStyle.Render(m.spinner.View()) +
229+ mutedStyle.Render("Initializing..."),
232+ case OAuthStateDisplay:
233+ instructions := lipgloss.NewStyle().
237+ whiteStyle.Render("Press ") +
238+ primaryStyle.Render("enter") +
239+ whiteStyle.Render(" to copy the code below and open the browser."),
242+ codeBox := lipgloss.NewStyle().
245+ Align(lipgloss.Center, lipgloss.Center).
246+ Background(t.BgBaseLighter).
249+ lipgloss.NewStyle().
251+ Foreground(t.White).
255+ link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL)
259+ Render("Browser not opening? Refer to\n" + link)
261+ waiting := greenStyle.
264+ Render(m.spinner.View() + "Verifying...")
266+ return lipgloss.JoinVertical(
274+ case OAuthStateSuccess:
278+ Align(lipgloss.Center).
279+ Render("Authentication successful!")
281+ case OAuthStateError:
282+ return lipgloss.NewStyle().
285+ Render(errorStyle.Render("Authentication failed."))
292+// FullHelp returns the full help view.
293+func (m *OAuth) FullHelp() [][]key.Binding {
294+ return [][]key.Binding{m.ShortHelp()}
297+// ShortHelp returns the full help view.
298+func (m *OAuth) ShortHelp() []key.Binding {
300+ case OAuthStateError:
301+ return []key.Binding{m.keyMap.Close}
303+ case OAuthStateSuccess:
304+ return []key.Binding{
306+ key.WithKeys("finish", "ctrl+y", "esc"),
307+ key.WithHelp("enter", "finish"),
312+ return []key.Binding{
320+func (d *OAuth) copyCode() tea.Cmd {
321+ if d.State != OAuthStateDisplay {
324+ return tea.Sequence(
325+ tea.SetClipboard(d.userCode),
326+ uiutil.ReportInfo("Code copied to clipboard"),
330+func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
331+ if d.State != OAuthStateDisplay {
334+ return tea.Sequence(
335+ tea.SetClipboard(d.userCode),
337+ if err := browser.OpenURL(d.verificationURL); err != nil {
338+ return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)}
342+ uiutil.ReportInfo("Code copied and URL opened"),
346+func (m *OAuth) saveKeyAndContinue() Action {
347+ cfg := m.com.Config()
349+ err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
351+ return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
354+ return ActionSelectModel{
355+ Provider: m.provider,
357+ ModelType: m.modelType,
361+func (m *OAuth) initiateDeviceAuth() tea.Msg {
362+ minimumWait := 750 * time.Millisecond
363+ startTime := time.Now()
365+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
368+ authResp, err := hyper.InitiateDeviceAuth(ctx)
370+ ellapsed := time.Since(startTime)
371+ if ellapsed < minimumWait {
372+ time.Sleep(minimumWait - ellapsed)
376+ return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)}
379+ return ActionInitiateOAuth{
380+ DeviceCode: authResp.DeviceCode,
381+ UserCode: authResp.UserCode,
382+ ExpiresIn: authResp.ExpiresIn,
383+ VerificationURL: authResp.VerificationURL,
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
393+ refreshToken, err := hyper.PollForToken(ctx, deviceCode, m.expiresIn)
395+ if ctx.Err() != nil {
398+ return ActionOAuthErrored{err}
401+ token, err := hyper.ExchangeToken(ctx, refreshToken)
403+ return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)}
406+ introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
408+ return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)}
410+ if !introspect.Active {
411+ return ActionOAuthErrored{fmt.Errorf("access token is not active")}
414+ return ActionCompleteOAuth{token}
418+func (m *OAuth) stopPolling() tea.Msg {
419+ if m.cancelFunc != nil {