1// Package copilot provides the dialog for Copilot device flow authentication.
2package copilot
3
4import (
5 "context"
6 "fmt"
7 "time"
8
9 "charm.land/bubbles/v2/spinner"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/charmbracelet/crush/internal/oauth"
13 "github.com/charmbracelet/crush/internal/oauth/copilot"
14 "github.com/charmbracelet/crush/internal/tui/styles"
15 "github.com/charmbracelet/crush/internal/tui/util"
16 "github.com/pkg/browser"
17)
18
19// DeviceFlowState represents the current state of the device flow.
20type DeviceFlowState int
21
22const (
23 DeviceFlowStateDisplay DeviceFlowState = iota
24 DeviceFlowStateSuccess
25 DeviceFlowStateError
26 DeviceFlowStateUnavailable
27)
28
29// DeviceAuthInitiatedMsg is sent when the device auth is initiated
30// successfully.
31type DeviceAuthInitiatedMsg struct {
32 deviceCode *copilot.DeviceCode
33}
34
35// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
36type DeviceFlowCompletedMsg struct {
37 Token *oauth.Token
38}
39
40// DeviceFlowErrorMsg is sent when the device flow encounters an error.
41type DeviceFlowErrorMsg struct {
42 Error error
43}
44
45// DeviceFlow handles the Copilot device flow authentication.
46type DeviceFlow struct {
47 State DeviceFlowState
48 width int
49 deviceCode *copilot.DeviceCode
50 token *oauth.Token
51 cancelFunc context.CancelFunc
52 spinner spinner.Model
53}
54
55// NewDeviceFlow creates a new device flow component.
56func NewDeviceFlow() *DeviceFlow {
57 s := spinner.New()
58 s.Spinner = spinner.Dot
59 s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
60 return &DeviceFlow{
61 State: DeviceFlowStateDisplay,
62 spinner: s,
63 }
64}
65
66// Init initializes the device flow by calling the device auth API and starting polling.
67func (d *DeviceFlow) Init() tea.Cmd {
68 return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
69}
70
71// Update handles messages and state transitions.
72func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
73 var cmd tea.Cmd
74 d.spinner, cmd = d.spinner.Update(msg)
75
76 switch msg := msg.(type) {
77 case DeviceAuthInitiatedMsg:
78 return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
79 case DeviceFlowCompletedMsg:
80 d.State = DeviceFlowStateSuccess
81 d.token = msg.Token
82 return d, nil
83 case DeviceFlowErrorMsg:
84 switch msg.Error {
85 case copilot.ErrNotAvailable:
86 d.State = DeviceFlowStateUnavailable
87 default:
88 d.State = DeviceFlowStateError
89 }
90 return d, nil
91 }
92
93 return d, cmd
94}
95
96// View renders the device flow dialog.
97func (d *DeviceFlow) View() string {
98 t := styles.CurrentTheme()
99
100 whiteStyle := lipgloss.NewStyle().Foreground(t.White)
101 primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
102 greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
103 linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
104 errorStyle := lipgloss.NewStyle().Foreground(t.Error)
105 mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
106
107 switch d.State {
108 case DeviceFlowStateDisplay:
109 if d.deviceCode == nil {
110 return lipgloss.NewStyle().
111 Margin(0, 1).
112 Render(
113 greenStyle.Render(d.spinner.View()) +
114 mutedStyle.Render("Initializing..."),
115 )
116 }
117
118 instructions := lipgloss.NewStyle().
119 Margin(1, 1, 0, 1).
120 Width(d.width - 2).
121 Render(
122 whiteStyle.Render("Press ") +
123 primaryStyle.Render("enter") +
124 whiteStyle.Render(" to copy the code below and open the browser."),
125 )
126
127 codeBox := lipgloss.NewStyle().
128 Width(d.width-2).
129 Height(7).
130 Align(lipgloss.Center, lipgloss.Center).
131 Background(t.BgBaseLighter).
132 Margin(1).
133 Render(
134 lipgloss.NewStyle().
135 Bold(true).
136 Foreground(t.White).
137 Render(d.deviceCode.UserCode),
138 )
139
140 uri := d.deviceCode.VerificationURI
141 link := lipgloss.NewStyle().Hyperlink(uri, "id=copilot-verify").Render(uri)
142 url := mutedStyle.
143 Margin(0, 1).
144 Width(d.width - 2).
145 Render("Browser not opening? Refer to\n" + link)
146
147 waiting := greenStyle.
148 Width(d.width-2).
149 Margin(1, 1, 0, 1).
150 Render(d.spinner.View() + "Verifying...")
151
152 return lipgloss.JoinVertical(
153 lipgloss.Left,
154 instructions,
155 codeBox,
156 url,
157 waiting,
158 )
159
160 case DeviceFlowStateSuccess:
161 return greenStyle.Margin(0, 1).Render("Authentication successful!")
162
163 case DeviceFlowStateError:
164 return lipgloss.NewStyle().
165 Margin(0, 1).
166 Width(d.width - 2).
167 Render(errorStyle.Render("Authentication failed."))
168
169 case DeviceFlowStateUnavailable:
170 message := lipgloss.NewStyle().
171 Margin(0, 1).
172 Width(d.width - 2).
173 Render("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
174 freeMessage := lipgloss.NewStyle().
175 Margin(0, 1).
176 Width(d.width - 2).
177 Render("You may be able to request free access if elegible. For more information, see:")
178 return lipgloss.JoinVertical(
179 lipgloss.Left,
180 message,
181 "",
182 linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL),
183 "",
184 freeMessage,
185 "",
186 linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL),
187 )
188
189 default:
190 return ""
191 }
192}
193
194// SetWidth sets the width of the dialog.
195func (d *DeviceFlow) SetWidth(w int) {
196 d.width = w
197}
198
199// Cursor hides the cursor.
200func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
201
202// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
203func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
204 switch d.State {
205 case DeviceFlowStateDisplay:
206 return tea.Sequence(
207 tea.SetClipboard(d.deviceCode.UserCode),
208 func() tea.Msg {
209 if err := browser.OpenURL(d.deviceCode.VerificationURI); err != nil {
210 return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
211 }
212 return nil
213 },
214 util.ReportInfo("Code copied and URL opened"),
215 )
216 case DeviceFlowStateUnavailable:
217 return tea.Sequence(
218 func() tea.Msg {
219 if err := browser.OpenURL(copilot.SignupURL); err != nil {
220 return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
221 }
222 return nil
223 },
224 util.ReportInfo("Code copied and URL opened"),
225 )
226 default:
227 return nil
228 }
229}
230
231// CopyCode copies just the user code to the clipboard.
232func (d *DeviceFlow) CopyCode() tea.Cmd {
233 if d.State != DeviceFlowStateDisplay {
234 return nil
235 }
236 return tea.Sequence(
237 tea.SetClipboard(d.deviceCode.UserCode),
238 util.ReportInfo("Code copied to clipboard"),
239 )
240}
241
242// Cancel cancels the device flow polling.
243func (d *DeviceFlow) Cancel() {
244 if d.cancelFunc != nil {
245 d.cancelFunc()
246 }
247}
248
249func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
250 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
251 defer cancel()
252
253 deviceCode, err := copilot.RequestDeviceCode(ctx)
254 if err != nil {
255 return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
256 }
257
258 d.deviceCode = deviceCode
259
260 return DeviceAuthInitiatedMsg{
261 deviceCode: d.deviceCode,
262 }
263}
264
265// startPolling starts polling for the device token.
266func (d *DeviceFlow) startPolling(deviceCode *copilot.DeviceCode) tea.Cmd {
267 return func() tea.Msg {
268 ctx, cancel := context.WithCancel(context.Background())
269 d.cancelFunc = cancel
270
271 token, err := copilot.PollForToken(ctx, deviceCode)
272 if err != nil {
273 if ctx.Err() != nil {
274 return nil // cancelled, don't report error.
275 }
276 return DeviceFlowErrorMsg{Error: err}
277 }
278
279 return DeviceFlowCompletedMsg{Token: token}
280 }
281}