@@ -43,6 +43,12 @@ type ghTokenMsg struct {
4343 token string // empty if gh not available
4444}
4545
46+ // deviceCodeMsg carries the result of a GitHub device code request.
47+ type deviceCodeMsg struct {
48+ result * providerauth.DeviceCodeResult
49+ err error
50+ }
51+
4652type onboardingStep int
4753
4854const (
@@ -116,12 +122,14 @@ type OnboardingModel struct {
116122 // Base URL input
117123 baseURLInput textinput.Model
118124
119- // Browser auth state
120- authing bool // browser/gh auth in progress
121- authError string
122- authToken string // token obtained from browser/gh auth
123- browserOpened bool // browser was opened for user
124- authCancel context.CancelFunc
125+ // Browser/device auth state
126+ authing bool // browser/gh/device auth in progress
127+ authError string
128+ authToken string // token obtained from auth flow
129+ browserOpened bool // browser was opened for user
130+ authCancel context.CancelFunc
131+ deviceUserCode string // device flow: code to display to user
132+ deviceVerificationURI string // device flow: URL to open
125133
126134 // Model selection
127135 modelCursor int
@@ -177,27 +185,47 @@ func (m OnboardingModel) Update(msg tea.Msg) (OnboardingModel, tea.Cmd) {
177185 return m , nil
178186
179187 case ghTokenMsg :
180- m .authing = false
181188 if msg .token != "" {
182189 // gh CLI provided a token — skip to model selection
190+ m .authing = false
183191 m .authToken = msg .token
184192 m .step = stepSelectModel
185193 m .modelCursor = 0
186194 return m , nil
187195 }
188- // gh not available — fall through to API key entry with instructions
189- m .browserOpened = false
190- m .step = stepEnterAPIKey
191- m .apiKeyInput .Placeholder = "ghp_..."
192- return m , m .apiKeyInput .Focus ()
196+ // gh not available — start GitHub device flow
197+ return m , m .startDeviceFlow ()
198+
199+ case deviceCodeMsg :
200+ if msg .err != nil {
201+ // Device flow request failed — fall to API key paste
202+ m .authing = false
203+ m .authError = msg .err .Error ()
204+ m .step = stepEnterAPIKey
205+ m .apiKeyInput .Placeholder = "ghp_..."
206+ return m , m .apiKeyInput .Focus ()
207+ }
208+ // Show user code and start polling
209+ m .deviceUserCode = msg .result .UserCode
210+ m .deviceVerificationURI = msg .result .VerificationURI
211+ m .browserOpened = true
212+ go providerauth .OpenBrowserURL (msg .result .VerificationURI ) //nolint:errcheck
213+ ctx , cancel := context .WithTimeout (context .Background (), time .Duration (msg .result .ExpiresIn )* time .Second )
214+ m .authCancel = cancel
215+ return m , m .pollDeviceFlow (ctx , msg .result .DeviceCode , msg .result .Interval )
193216
194217 case browserAuthResultMsg :
195218 m .authing = false
196219 if msg .err != nil {
197- // OAuth failed — fall back to API key paste
220+ // Auth failed — fall back to API key paste
198221 m .authError = msg .err .Error ()
199222 m .step = stepEnterAPIKey
200- m .apiKeyInput .Placeholder = "sk-ant-..."
223+ p := m .selectedProvider ()
224+ if p .auth == authGHCLI {
225+ m .apiKeyInput .Placeholder = "ghp_..."
226+ } else {
227+ m .apiKeyInput .Placeholder = "sk-ant-..."
228+ }
201229 return m , m .apiKeyInput .Focus ()
202230 }
203231 m .authToken = msg .token
@@ -283,6 +311,8 @@ func (m OnboardingModel) advanceFromProvider() (OnboardingModel, tea.Cmd) {
283311 m .authToken = ""
284312 m .authError = ""
285313 m .browserOpened = false
314+ m .deviceUserCode = ""
315+ m .deviceVerificationURI = ""
286316
287317 switch p .auth {
288318 case authBrowser :
@@ -326,6 +356,21 @@ func (m OnboardingModel) tryGHToken() tea.Cmd {
326356 }
327357}
328358
359+ func (m OnboardingModel ) startDeviceFlow () tea.Cmd {
360+ return func () tea.Msg {
361+ result , err := providerauth .StartGitHubDeviceFlow (context .Background (), providerauth .GithubCopilotClientID )
362+ return deviceCodeMsg {result : result , err : err }
363+ }
364+ }
365+
366+ func (m OnboardingModel ) pollDeviceFlow (ctx context.Context , deviceCode string , interval int ) tea.Cmd {
367+ return func () tea.Msg {
368+ ch := providerauth .PollGitHubDeviceFlow (ctx , providerauth .GithubCopilotClientID , deviceCode , interval )
369+ result := <- ch
370+ return browserAuthResultMsg {token : result .Token , err : result .Err }
371+ }
372+ }
373+
329374func (m OnboardingModel ) startAnthropicAuth (ctx context.Context ) tea.Cmd {
330375 return func () tea.Msg {
331376 ch := providerauth .StartAnthropicOAuth (ctx )
@@ -576,7 +621,15 @@ func (m OnboardingModel) View(t theme.Theme, width, height int) string {
576621 case stepBrowserAuth :
577622 p := m .selectedProvider ()
578623 if m .authing {
579- if p .auth == authGHCLI {
624+ if p .auth == authGHCLI && m .deviceUserCode != "" {
625+ // Device flow: show user code
626+ sb .WriteString ("Sign in with GitHub Copilot\n \n " )
627+ codeStyle := lipgloss .NewStyle ().Bold (true ).Foreground (t .Accent )
628+ sb .WriteString ("Your code: " + codeStyle .Render (m .deviceUserCode ) + "\n \n " )
629+ sb .WriteString (m .spinner .View () + " Waiting for authorization...\n \n " )
630+ sb .WriteString (mutedStyle .Render ("Enter the code at " + m .deviceVerificationURI ) + "\n " )
631+ sb .WriteString (mutedStyle .Render ("Esc: cancel" ))
632+ } else if p .auth == authGHCLI {
580633 sb .WriteString (m .spinner .View () + " Checking for GitHub CLI auth...\n " )
581634 } else {
582635 sb .WriteString ("Sign in with " + p .displayName + "\n \n " )
0 commit comments