@@ -0,0 +1,369 @@
8+ "charm.land/bubbles/v2/help"
9+ "charm.land/bubbles/v2/key"
10+ "charm.land/bubbles/v2/spinner"
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/lipgloss/v2"
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/oauth"
16+ "github.com/charmbracelet/crush/internal/ui/common"
17+ "github.com/charmbracelet/crush/internal/uiutil"
18+ uv "github.com/charmbracelet/ultraviolet"
19+ "github.com/pkg/browser"
22+type OAuthProvider interface {
24+ initiateAuth() tea.Msg
25+ startPolling(deviceCode string, expiresIn int) tea.Cmd
26+ stopPolling() tea.Msg
29+// OAuthState represents the current state of the device flow.
33+ OAuthStateInitializing OAuthState = iota
39+// OAuthID is the identifier for the model selection dialog.
40+const OAuthID = "oauth"
42+// OAuth handles the OAuth flow authentication.
46+ provider catwalk.Provider
47+ model config.SelectedModel
48+ modelType config.SelectedModelType
49+ oAuthProvider OAuthProvider
53+ spinner spinner.Model
64+ verificationURL string
68+ cancelFunc context.CancelFunc
71+var _ Dialog = (*OAuth)(nil)
73+// newOAuth creates a new device flow component.
74+func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, tea.Cmd) {
79+ m.provider = provider
81+ m.modelType = modelType
82+ m.oAuthProvider = oAuthProvider
84+ m.State = OAuthStateInitializing
86+ m.spinner = spinner.New(
87+ spinner.WithSpinner(spinner.Dot),
88+ spinner.WithStyle(t.Base.Foreground(t.GreenLight)),
92+ m.help.Styles = t.DialogHelpStyles()
94+ m.keyMap.Copy = key.NewBinding(
96+ key.WithHelp("c", "copy code"),
98+ m.keyMap.Submit = key.NewBinding(
99+ key.WithKeys("enter", "ctrl+y"),
100+ key.WithHelp("enter", "copy & open"),
102+ m.keyMap.Close = CloseKey
104+ return &m, tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth)
107+// ID implements Dialog.
108+func (m *OAuth) ID() string {
112+// HandleMsg handles messages and state transitions.
113+func (m *OAuth) HandleMsg(msg tea.Msg) Action {
114+ switch msg := msg.(type) {
115+ case spinner.TickMsg:
117+ case OAuthStateInitializing, OAuthStateDisplay:
119+ m.spinner, cmd = m.spinner.Update(msg)
121+ return ActionCmd{cmd}
125+ case tea.KeyPressMsg:
127+ case key.Matches(msg, m.keyMap.Copy):
129+ return ActionCmd{cmd}
131+ case key.Matches(msg, m.keyMap.Submit):
133+ case OAuthStateSuccess:
134+ return m.saveKeyAndContinue()
137+ cmd := m.copyCodeAndOpenURL()
138+ return ActionCmd{cmd}
141+ case key.Matches(msg, m.keyMap.Close):
143+ case OAuthStateSuccess:
144+ return m.saveKeyAndContinue()
147+ return ActionClose{}
151+ case ActionInitiateOAuth:
152+ m.deviceCode = msg.DeviceCode
153+ m.userCode = msg.UserCode
154+ m.expiresIn = msg.ExpiresIn
155+ m.verificationURL = msg.VerificationURL
156+ m.interval = msg.Interval
157+ m.State = OAuthStateDisplay
158+ return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)}
160+ case ActionCompleteOAuth:
161+ m.State = OAuthStateSuccess
163+ return ActionCmd{m.oAuthProvider.stopPolling}
165+ case ActionOAuthErrored:
166+ m.State = OAuthStateError
167+ cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error))
168+ return ActionCmd{cmd}
173+// View renders the device flow dialog.
174+func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
177+ dialogStyle = t.Dialog.View.Width(m.width)
178+ view = dialogStyle.Render(m.dialogContent())
180+ DrawCenterCursor(scr, area, view, nil)
184+func (m *OAuth) dialogContent() string {
187+ helpStyle = t.Dialog.HelpView
191+ case OAuthStateInitializing:
192+ return m.innerDialogContent()
195+ elements := []string{
197+ m.innerDialogContent(),
198+ helpStyle.Render(m.help.View(m)),
200+ return strings.Join(elements, "\n")
204+func (m *OAuth) headerContent() string {
207+ titleStyle = t.Dialog.Title
208+ dialogStyle = t.Dialog.View.Width(m.width)
209+ headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
211+ return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset)
214+func (m *OAuth) innerDialogContent() string {
217+ whiteStyle = lipgloss.NewStyle().Foreground(t.White)
218+ primaryStyle = lipgloss.NewStyle().Foreground(t.Primary)
219+ greenStyle = lipgloss.NewStyle().Foreground(t.GreenLight)
220+ linkStyle = lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
221+ errorStyle = lipgloss.NewStyle().Foreground(t.Error)
222+ mutedStyle = lipgloss.NewStyle().Foreground(t.FgMuted)
226+ case OAuthStateInitializing:
227+ return lipgloss.NewStyle().
230+ Align(lipgloss.Center).
232+ greenStyle.Render(m.spinner.View()) +
233+ mutedStyle.Render("Initializing..."),
236+ case OAuthStateDisplay:
237+ instructions := lipgloss.NewStyle().
241+ whiteStyle.Render("Press ") +
242+ primaryStyle.Render("enter") +
243+ whiteStyle.Render(" to copy the code below and open the browser."),
246+ codeBox := lipgloss.NewStyle().
249+ Align(lipgloss.Center, lipgloss.Center).
250+ Background(t.BgBaseLighter).
253+ lipgloss.NewStyle().
255+ Foreground(t.White).
259+ link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL)
263+ Render("Browser not opening? Refer to\n" + link)
265+ waiting := lipgloss.NewStyle().
269+ greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."),
272+ return lipgloss.JoinVertical(
285+ case OAuthStateSuccess:
289+ Render("Authentication successful!")
291+ case OAuthStateError:
292+ return lipgloss.NewStyle().
295+ Render(errorStyle.Render("Authentication failed."))
302+// FullHelp returns the full help view.
303+func (m *OAuth) FullHelp() [][]key.Binding {
304+ return [][]key.Binding{m.ShortHelp()}
307+// ShortHelp returns the full help view.
308+func (m *OAuth) ShortHelp() []key.Binding {
310+ case OAuthStateError:
311+ return []key.Binding{m.keyMap.Close}
313+ case OAuthStateSuccess:
314+ return []key.Binding{
316+ key.WithKeys("finish", "ctrl+y", "esc"),
317+ key.WithHelp("enter", "finish"),
322+ return []key.Binding{
330+func (d *OAuth) copyCode() tea.Cmd {
331+ if d.State != OAuthStateDisplay {
334+ return tea.Sequence(
335+ tea.SetClipboard(d.userCode),
336+ uiutil.ReportInfo("Code copied to clipboard"),
340+func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
341+ if d.State != OAuthStateDisplay {
344+ return tea.Sequence(
345+ tea.SetClipboard(d.userCode),
347+ if err := browser.OpenURL(d.verificationURL); err != nil {
348+ return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)}
352+ uiutil.ReportInfo("Code copied and URL opened"),
356+func (m *OAuth) saveKeyAndContinue() Action {
357+ cfg := m.com.Config()
359+ err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
361+ return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
364+ return ActionSelectModel{
365+ Provider: m.provider,
367+ ModelType: m.modelType,