on main help sign in
feat: add github copilot auth flow via tui
commit5fe017723303e8cdd40f4f14de9da46c911e761f authorAndrey Nering <andreynering@users.noreply.github.com> dateWed 17 Dec 2025, 14:49 (1 year ago) parentcddb306
8 files changed+394−15
internal/cmd/login.go+13 internal/config/config.go+6−2 internal/oauth/copilot/oauth.go+6−5 internal/oauth/copilot/urls.go+6 internal/tui/components/dialogs/copilot/device_flow.go+281 internal/tui/components/dialogs/models/keys.go+13−2 internal/tui/components/dialogs/models/list.go+2−1 internal/tui/components/dialogs/models/models.go+67−5
internal/cmd/login.go+13
@@ -33,6 +33,9 @@ crush login
3333
3434# Authenticate with Claude Code Max
3535crush login claude
36+
37+# Authenticate with GitHub Copilot
38+crush login copilot
3639 `,
3740 ValidArgs: []cobra.Completion{
3841 "hyper",
@@ -223,6 +226,16 @@ func loginCopilot() error {
223226 fmt.Println("Waiting for authorization...")
224227
225228 t, err := copilot.PollForToken(ctx, dc)
229+ if err == copilot.ErrNotAvailable {
230+ fmt.Println()
231+ fmt.Println("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
232+ fmt.Println()
233+ fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL))
234+ fmt.Println()
235+ fmt.Println("You may be able to request free access if elegible. For more information, see:")
236+ fmt.Println()
237+ fmt.Println(lipgloss.NewStyle().Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL))
238+ }
226239 if err != nil {
227240 return err
228241 }
internal/config/config.go+6−2
@@ -558,10 +558,14 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
558558 return err
559559 }
560560 setKeyOrToken = func() {
561− providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken)
561+ providerConfig.APIKey = v.AccessToken
562562 providerConfig.OAuthToken = v
563− if providerID == string(catwalk.InferenceProviderAnthropic) {
563+ switch providerID {
564+ case string(catwalk.InferenceProviderAnthropic):
565+ providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken)
564566 providerConfig.SetupClaudeCode()
567+ case string(catwalk.InferenceProviderCopilot):
568+ providerConfig.SetupGitHubCopilot()
565569 }
566570 }
567571 }
internal/oauth/copilot/oauth.go+6−5
@@ -3,6 +3,7 @@ package copilot
33import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "io"
89 "net/http"
@@ -21,6 +22,8 @@ const (
2122 copilotTokenURL = "https://api.github.com/copilot_internal/v2/token"
2223)
2324
25+var ErrNotAvailable = errors.New("github copilot not available")
26+
2427type DeviceCode struct {
2528 DeviceCode string `json:"device_code"`
2629 UserCode string `json:"user_code"`
@@ -166,12 +169,10 @@ func getCopilotToken(ctx context.Context, githubToken string) (*oauth.Token, err
166169 return nil, err
167170 }
168171
172+ if resp.StatusCode == http.StatusForbidden {
173+ return nil, ErrNotAvailable
174+ }
169175 if resp.StatusCode != http.StatusOK {
170− if resp.StatusCode == http.StatusNotFound {
171− return nil, fmt.Errorf("copilot not available for this account\n\n" +
172− "Please ensure you have GitHub Copilot enabled at:\n" +
173− "https://github.com/settings/copilot")
174− }
175176 return nil, fmt.Errorf("copilot token request failed: %s - %s", resp.Status, string(body))
176177 }
177178
internal/oauth/copilot/urls.gonew file+6
@@ -0,0 +1,6 @@
1+package copilot
2+
3+const (
4+ SignupURL = "https://github.com/github-copilot/signup?editor=crush"
5+ FreeURL = "https://docs.github.com/en/copilot/how-tos/manage-your-account/get-free-access-to-copilot-pro"
6+)
internal/tui/components/dialogs/copilot/device_flow.gonew file+281
@@ -0,0 +1,281 @@
1+// Package copilot provides the dialog for Copilot device flow authentication.
2+package copilot
3+
4+import (
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.
20+type DeviceFlowState int
21+
22+const (
23+ DeviceFlowStateDisplay DeviceFlowState = iota
24+ DeviceFlowStateSuccess
25+ DeviceFlowStateError
26+ DeviceFlowStateUnavailable
27+)
28+
29+// DeviceAuthInitiatedMsg is sent when the device auth is initiated
30+// successfully.
31+type DeviceAuthInitiatedMsg struct {
32+ deviceCode *copilot.DeviceCode
33+}
34+
35+// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
36+type DeviceFlowCompletedMsg struct {
37+ Token *oauth.Token
38+}
39+
40+// DeviceFlowErrorMsg is sent when the device flow encounters an error.
41+type DeviceFlowErrorMsg struct {
42+ Error error
43+}
44+
45+// DeviceFlow handles the Copilot device flow authentication.
46+type 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.
56+func 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.
67+func (d *DeviceFlow) Init() tea.Cmd {
68+ return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
69+}
70+
71+// Update handles messages and state transitions.
72+func (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.
97+func (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.
195+func (d *DeviceFlow) SetWidth(w int) {
196+ d.width = w
197+}
198+
199+// Cursor hides the cursor.
200+func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
201+
202+// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
203+func (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.
232+func (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.
243+func (d *DeviceFlow) Cancel() {
244+ if d.cancelFunc != nil {
245+ d.cancelFunc()
246+ }
247+}
248+
249+func (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.
266+func (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+}
internal/tui/components/dialogs/models/keys.go+13−2
@@ -15,7 +15,9 @@ type KeyMap struct {
1515 isAPIKeyHelp bool
1616 isAPIKeyValid bool
1717
18− isHyperDeviceFlow bool
18+ isHyperDeviceFlow bool
19+ isCopilotDeviceFlow bool
20+ isCopilotUnavailable bool
1921
2022 isClaudeAuthChoiceHelp bool
2123 isClaudeOAuthHelp bool
@@ -76,7 +78,7 @@ func (k KeyMap) FullHelp() [][]key.Binding {
7678
7779// ShortHelp implements help.KeyMap.
7880func (k KeyMap) ShortHelp() []key.Binding {
79− if k.isHyperDeviceFlow {
81+ if k.isHyperDeviceFlow || k.isCopilotDeviceFlow {
8082 return []key.Binding{
8183 key.NewBinding(
8284 key.WithKeys("c"),
@@ -89,6 +91,15 @@ func (k KeyMap) ShortHelp() []key.Binding {
8991 k.Close,
9092 }
9193 }
94+ if k.isCopilotUnavailable {
95+ return []key.Binding{
96+ key.NewBinding(
97+ key.WithKeys("enter"),
98+ key.WithHelp("enter", "open signup"),
99+ ),
100+ k.Close,
101+ }
102+ }
92103 if k.isClaudeAuthChoiceHelp {
93104 return []key.Binding{
94105 key.NewBinding(
internal/tui/components/dialogs/models/list.go+2−1
@@ -63,7 +63,8 @@ func (m *ModelListComponent) Init() tea.Cmd {
6363 for _, p := range providers {
6464 hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
6565 isHyper := p.ID == "hyper"
66− if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper {
66+ isCopilot := p.ID == catwalk.InferenceProviderCopilot
67+ if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper || isCopilot {
6768 filteredProviders = append(filteredProviders, p)
6869 }
6970 }
internal/tui/components/dialogs/models/models.go+67−5
@@ -17,6 +17,7 @@ import (
1717 "github.com/charmbracelet/crush/internal/tui/components/core"
1818 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
1919 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
20+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
2021 "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
2122 "github.com/charmbracelet/crush/internal/tui/exp/list"
2223 "github.com/charmbracelet/crush/internal/tui/styles"
@@ -77,6 +78,10 @@ type modelDialogCmp struct {
7778 hyperDeviceFlow *hyper.DeviceFlow
7879 showHyperDeviceFlow bool
7980
81+ // Copilot device flow state
82+ copilotDeviceFlow *copilot.DeviceFlow
83+ showCopilotDeviceFlow bool
84+
8085 // Claude state
8186 claudeAuthMethodChooser *claude.AuthMethodChooser
8287 claudeOAuth2 *claude.OAuth2
@@ -143,6 +148,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
143148 return m, cmd
144149 }
145150 return m, nil
151+ case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
152+ if m.copilotDeviceFlow != nil {
153+ u, cmd := m.copilotDeviceFlow.Update(msg)
154+ m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
155+ return m, cmd
156+ }
157+ return m, nil
158+ case copilot.DeviceFlowCompletedMsg:
159+ return m, m.saveOauthTokenAndContinue(msg.Token, true)
146160 case claude.ValidationCompletedMsg:
147161 var cmds []tea.Cmd
148162 u, cmd := m.claudeOAuth2.Update(msg)
@@ -160,10 +174,13 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
160174 case tea.KeyPressMsg:
161175 switch {
162176 // Handle Hyper device flow keys
163− case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow:
177+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && (m.showHyperDeviceFlow || m.showCopilotDeviceFlow):
164178 if m.hyperDeviceFlow != nil {
165179 return m, m.hyperDeviceFlow.CopyCode()
166180 }
181+ if m.copilotDeviceFlow != nil {
182+ return m, m.copilotDeviceFlow.CopyCode()
183+ }
167184 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
168185 return m, tea.Sequence(
169186 tea.SetClipboard(m.claudeOAuth2.URL),
@@ -181,6 +198,9 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
181198 if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
182199 return m, m.hyperDeviceFlow.CopyCodeAndOpenURL()
183200 }
201+ if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
202+ return m, m.copilotDeviceFlow.CopyCodeAndOpenURL()
203+ }
184204 selectedItem := m.modelList.SelectedModel()
185205
186206 modelType := config.SelectedModelTypeLarge
@@ -193,6 +213,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
193213 m.keyMap.isClaudeOAuthHelp = false
194214 m.keyMap.isAPIKeyHelp = true
195215 m.showHyperDeviceFlow = false
216+ m.showCopilotDeviceFlow = false
196217 m.showClaudeAuthMethodChooser = false
197218 m.needsAPIKey = true
198219 m.selectedModel = selectedItem
@@ -288,6 +309,13 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
288309 m.hyperDeviceFlow = hyper.NewDeviceFlow()
289310 m.hyperDeviceFlow.SetWidth(m.width - 2)
290311 return m, m.hyperDeviceFlow.Init()
312+ case catwalk.InferenceProviderCopilot:
313+ m.showCopilotDeviceFlow = true
314+ m.selectedModel = selectedItem
315+ m.selectedModelType = modelType
316+ m.copilotDeviceFlow = copilot.NewDeviceFlow()
317+ m.copilotDeviceFlow.SetWidth(m.width - 2)
318+ return m, m.copilotDeviceFlow.Init()
291319 }
292320 // For other providers, show API key input
293321 askForApiKey()
@@ -310,13 +338,19 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
310338 }
311339 case key.Matches(msg, m.keyMap.Close):
312340 if m.showHyperDeviceFlow {
313− // Cancel device flow and go back to model selection
314341 if m.hyperDeviceFlow != nil {
315342 m.hyperDeviceFlow.Cancel()
316343 }
317344 m.showHyperDeviceFlow = false
318345 m.selectedModel = nil
319346 }
347+ if m.showCopilotDeviceFlow {
348+ if m.copilotDeviceFlow != nil {
349+ m.copilotDeviceFlow.Cancel()
350+ }
351+ m.showCopilotDeviceFlow = false
352+ m.selectedModel = nil
353+ }
320354 if m.showClaudeAuthMethodChooser {
321355 m.claudeAuthMethodChooser.SetDefaults()
322356 m.showClaudeAuthMethodChooser = false
@@ -377,18 +411,27 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
377411 u, cmd = m.hyperDeviceFlow.Update(msg)
378412 m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
379413 }
414+ if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
415+ u, cmd = m.copilotDeviceFlow.Update(msg)
416+ m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
417+ }
380418 return m, cmd
381419 default:
382420 // Pass all other messages to the device flow for spinner animation
383− if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
421+ switch {
422+ case m.showHyperDeviceFlow && m.hyperDeviceFlow != nil:
384423 u, cmd := m.hyperDeviceFlow.Update(msg)
385424 m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
386425 return m, cmd
387− } else if m.showClaudeOAuth2 {
426+ case m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil:
427+ u, cmd := m.copilotDeviceFlow.Update(msg)
428+ m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
429+ return m, cmd
430+ case m.showClaudeOAuth2:
388431 u, cmd := m.claudeOAuth2.Update(msg)
389432 m.claudeOAuth2 = u.(*claude.OAuth2)
390433 return m, cmd
391− } else {
434+ default:
392435 u, cmd := m.apiKeyInput.Update(msg)
393436 m.apiKeyInput = u.(*APIKeyInput)
394437 return m, cmd
@@ -413,9 +456,25 @@ func (m *modelDialogCmp) View() string {
413456 )
414457 return m.style().Render(content)
415458 }
459+ if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
460+ // Show Hyper device flow
461+ m.keyMap.isCopilotDeviceFlow = m.copilotDeviceFlow.State != copilot.DeviceFlowStateUnavailable
462+ m.keyMap.isCopilotUnavailable = m.copilotDeviceFlow.State == copilot.DeviceFlowStateUnavailable
463+ deviceFlowView := m.copilotDeviceFlow.View()
464+ content := lipgloss.JoinVertical(
465+ lipgloss.Left,
466+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with GitHub Copilot", m.width-4)),
467+ deviceFlowView,
468+ "",
469+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
470+ )
471+ return m.style().Render(content)
472+ }
416473
417474 // Reset the flags when not showing device flow
418475 m.keyMap.isHyperDeviceFlow = false
476+ m.keyMap.isCopilotDeviceFlow = false
477+ m.keyMap.isCopilotUnavailable = false
419478
420479 switch {
421480 case m.showClaudeAuthMethodChooser:
@@ -472,6 +531,9 @@ func (m *modelDialogCmp) Cursor() *tea.Cursor {
472531 if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
473532 return m.hyperDeviceFlow.Cursor()
474533 }
534+ if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
535+ return m.copilotDeviceFlow.Cursor()
536+ }
475537 if m.showClaudeAuthMethodChooser {
476538 return nil
477539 }