on main help sign in
Merge branch 'ui' into ui-picker
commitbe4695376dd9f15764b4c20609089a2dd071df3a authorAyman Bagabas <ayman.bagabas@gmail.com> dateFri 16 Jan 2026, 09:27 (9 months ago) parent1f0605a8b5cc43
8 files changed+623−23
internal/ui/dialog/actions.go+24 internal/ui/dialog/api_key_input.go+1−1 internal/ui/dialog/models.go+9−3 internal/ui/dialog/oauth.go+369 internal/ui/dialog/oauth_copilot.go+72 internal/ui/dialog/oauth_hyper.go+90 internal/ui/model/ui.go+50−17 internal/ui/styles/styles.go+8−2
internal/ui/dialog/actions.go+24
@@ -11,6 +11,7 @@ import (
1111 "github.com/charmbracelet/crush/internal/commands"
1212 "github.com/charmbracelet/crush/internal/config"
1313 "github.com/charmbracelet/crush/internal/message"
14+ "github.com/charmbracelet/crush/internal/oauth"
1415 "github.com/charmbracelet/crush/internal/permission"
1516 "github.com/charmbracelet/crush/internal/session"
1617 "github.com/charmbracelet/crush/internal/ui/common"
@@ -81,6 +82,29 @@ type (
8182 }
8283)
8384
85+// Messages for OAuth2 device flow dialog.
86+type (
87+ // ActionInitiateOAuth is sent when the device auth is initiated
88+ // successfully.
89+ ActionInitiateOAuth struct {
90+ DeviceCode string
91+ UserCode string
92+ ExpiresIn int
93+ VerificationURL string
94+ Interval int
95+ }
96+
97+ // ActionCompleteOAuth is sent when the device flow completes successfully.
98+ ActionCompleteOAuth struct {
99+ Token *oauth.Token
100+ }
101+
102+ // ActionOAuthErrored is sent when the device flow encounters an error.
103+ ActionOAuthErrored struct {
104+ Error error
105+ }
106+)
107+
84108// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
85109// Bubble Tea program loop.
86110type ActionCmd struct {
internal/ui/dialog/api_key_input.go+1−1
@@ -54,7 +54,7 @@ type APIKeyInput struct {
5454var _ Dialog = (*APIKeyInput)(nil)
5555
5656// NewAPIKeyInput creates a new Models dialog.
57−func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, error) {
57+func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, tea.Cmd) {
5858 t := com.Styles
5959
6060 m := APIKeyInput{}
internal/ui/dialog/models.go+9−3
@@ -454,10 +454,16 @@ func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) {
454454 if err != nil {
455455 return nil, fmt.Errorf("failed to get providers: %w", err)
456456 }
457− filteredProviders := []catwalk.Provider{}
457+ var filteredProviders []catwalk.Provider
458458 for _, p := range providers {
459− hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
460− if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
459+ var (
460+ isAzure = p.ID == catwalk.InferenceProviderAzure
461+ isCopilot = p.ID == catwalk.InferenceProviderCopilot
462+ isHyper = string(p.ID) == "hyper"
463+ hasAPIKeyEnv = strings.HasPrefix(p.APIKey, "$")
464+ _, isConfigured = cfg.Providers.Get(string(p.ID))
465+ )
466+ if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured {
461467 filteredProviders = append(filteredProviders, p)
462468 }
463469 }
internal/ui/dialog/oauth.gonew file+369
@@ -0,0 +1,369 @@
1+package dialog
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
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"
20+)
21+
22+type OAuthProvider interface {
23+ name() string
24+ initiateAuth() tea.Msg
25+ startPolling(deviceCode string, expiresIn int) tea.Cmd
26+ stopPolling() tea.Msg
27+}
28+
29+// OAuthState represents the current state of the device flow.
30+type OAuthState int
31+
32+const (
33+ OAuthStateInitializing OAuthState = iota
34+ OAuthStateDisplay
35+ OAuthStateSuccess
36+ OAuthStateError
37+)
38+
39+// OAuthID is the identifier for the model selection dialog.
40+const OAuthID = "oauth"
41+
42+// OAuth handles the OAuth flow authentication.
43+type OAuth struct {
44+ com *common.Common
45+
46+ provider catwalk.Provider
47+ model config.SelectedModel
48+ modelType config.SelectedModelType
49+ oAuthProvider OAuthProvider
50+
51+ State OAuthState
52+
53+ spinner spinner.Model
54+ help help.Model
55+ keyMap struct {
56+ Copy key.Binding
57+ Submit key.Binding
58+ Close key.Binding
59+ }
60+
61+ width int
62+ deviceCode string
63+ userCode string
64+ verificationURL string
65+ expiresIn int
66+ interval int
67+ token *oauth.Token
68+ cancelFunc context.CancelFunc
69+}
70+
71+var _ Dialog = (*OAuth)(nil)
72+
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) {
75+ t := com.Styles
76+
77+ m := OAuth{}
78+ m.com = com
79+ m.provider = provider
80+ m.model = model
81+ m.modelType = modelType
82+ m.oAuthProvider = oAuthProvider
83+ m.width = 60
84+ m.State = OAuthStateInitializing
85+
86+ m.spinner = spinner.New(
87+ spinner.WithSpinner(spinner.Dot),
88+ spinner.WithStyle(t.Base.Foreground(t.GreenLight)),
89+ )
90+
91+ m.help = help.New()
92+ m.help.Styles = t.DialogHelpStyles()
93+
94+ m.keyMap.Copy = key.NewBinding(
95+ key.WithKeys("c"),
96+ key.WithHelp("c", "copy code"),
97+ )
98+ m.keyMap.Submit = key.NewBinding(
99+ key.WithKeys("enter", "ctrl+y"),
100+ key.WithHelp("enter", "copy & open"),
101+ )
102+ m.keyMap.Close = CloseKey
103+
104+ return &m, tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth)
105+}
106+
107+// ID implements Dialog.
108+func (m *OAuth) ID() string {
109+ return OAuthID
110+}
111+
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:
116+ switch m.State {
117+ case OAuthStateInitializing, OAuthStateDisplay:
118+ var cmd tea.Cmd
119+ m.spinner, cmd = m.spinner.Update(msg)
120+ if cmd != nil {
121+ return ActionCmd{cmd}
122+ }
123+ }
124+
125+ case tea.KeyPressMsg:
126+ switch {
127+ case key.Matches(msg, m.keyMap.Copy):
128+ cmd := m.copyCode()
129+ return ActionCmd{cmd}
130+
131+ case key.Matches(msg, m.keyMap.Submit):
132+ switch m.State {
133+ case OAuthStateSuccess:
134+ return m.saveKeyAndContinue()
135+
136+ default:
137+ cmd := m.copyCodeAndOpenURL()
138+ return ActionCmd{cmd}
139+ }
140+
141+ case key.Matches(msg, m.keyMap.Close):
142+ switch m.State {
143+ case OAuthStateSuccess:
144+ return m.saveKeyAndContinue()
145+
146+ default:
147+ return ActionClose{}
148+ }
149+ }
150+
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)}
159+
160+ case ActionCompleteOAuth:
161+ m.State = OAuthStateSuccess
162+ m.token = msg.Token
163+ return ActionCmd{m.oAuthProvider.stopPolling}
164+
165+ case ActionOAuthErrored:
166+ m.State = OAuthStateError
167+ cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error))
168+ return ActionCmd{cmd}
169+ }
170+ return nil
171+}
172+
173+// View renders the device flow dialog.
174+func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
175+ var (
176+ t = m.com.Styles
177+ dialogStyle = t.Dialog.View.Width(m.width)
178+ view = dialogStyle.Render(m.dialogContent())
179+ )
180+ DrawCenterCursor(scr, area, view, nil)
181+ return nil
182+}
183+
184+func (m *OAuth) dialogContent() string {
185+ var (
186+ t = m.com.Styles
187+ helpStyle = t.Dialog.HelpView
188+ )
189+
190+ switch m.State {
191+ case OAuthStateInitializing:
192+ return m.innerDialogContent()
193+
194+ default:
195+ elements := []string{
196+ m.headerContent(),
197+ m.innerDialogContent(),
198+ helpStyle.Render(m.help.View(m)),
199+ }
200+ return strings.Join(elements, "\n")
201+ }
202+}
203+
204+func (m *OAuth) headerContent() string {
205+ var (
206+ t = m.com.Styles
207+ titleStyle = t.Dialog.Title
208+ dialogStyle = t.Dialog.View.Width(m.width)
209+ headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
210+ )
211+ return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset)
212+}
213+
214+func (m *OAuth) innerDialogContent() string {
215+ var (
216+ t = m.com.Styles
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)
223+ )
224+
225+ switch m.State {
226+ case OAuthStateInitializing:
227+ return lipgloss.NewStyle().
228+ Margin(1, 1).
229+ Width(m.width - 2).
230+ Align(lipgloss.Center).
231+ Render(
232+ greenStyle.Render(m.spinner.View()) +
233+ mutedStyle.Render("Initializing..."),
234+ )
235+
236+ case OAuthStateDisplay:
237+ instructions := lipgloss.NewStyle().
238+ Margin(0, 1).
239+ Width(m.width - 2).
240+ Render(
241+ whiteStyle.Render("Press ") +
242+ primaryStyle.Render("enter") +
243+ whiteStyle.Render(" to copy the code below and open the browser."),
244+ )
245+
246+ codeBox := lipgloss.NewStyle().
247+ Width(m.width-2).
248+ Height(7).
249+ Align(lipgloss.Center, lipgloss.Center).
250+ Background(t.BgBaseLighter).
251+ Margin(0, 1).
252+ Render(
253+ lipgloss.NewStyle().
254+ Bold(true).
255+ Foreground(t.White).
256+ Render(m.userCode),
257+ )
258+
259+ link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL)
260+ url := mutedStyle.
261+ Margin(0, 1).
262+ Width(m.width - 2).
263+ Render("Browser not opening? Refer to\n" + link)
264+
265+ waiting := lipgloss.NewStyle().
266+ Margin(0, 1).
267+ Width(m.width - 2).
268+ Render(
269+ greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."),
270+ )
271+
272+ return lipgloss.JoinVertical(
273+ lipgloss.Left,
274+ "",
275+ instructions,
276+ "",
277+ codeBox,
278+ "",
279+ url,
280+ "",
281+ waiting,
282+ "",
283+ )
284+
285+ case OAuthStateSuccess:
286+ return greenStyle.
287+ Margin(1).
288+ Width(m.width - 2).
289+ Render("Authentication successful!")
290+
291+ case OAuthStateError:
292+ return lipgloss.NewStyle().
293+ Margin(1).
294+ Width(m.width - 2).
295+ Render(errorStyle.Render("Authentication failed."))
296+
297+ default:
298+ return ""
299+ }
300+}
301+
302+// FullHelp returns the full help view.
303+func (m *OAuth) FullHelp() [][]key.Binding {
304+ return [][]key.Binding{m.ShortHelp()}
305+}
306+
307+// ShortHelp returns the full help view.
308+func (m *OAuth) ShortHelp() []key.Binding {
309+ switch m.State {
310+ case OAuthStateError:
311+ return []key.Binding{m.keyMap.Close}
312+
313+ case OAuthStateSuccess:
314+ return []key.Binding{
315+ key.NewBinding(
316+ key.WithKeys("finish", "ctrl+y", "esc"),
317+ key.WithHelp("enter", "finish"),
318+ ),
319+ }
320+
321+ default:
322+ return []key.Binding{
323+ m.keyMap.Copy,
324+ m.keyMap.Submit,
325+ m.keyMap.Close,
326+ }
327+ }
328+}
329+
330+func (d *OAuth) copyCode() tea.Cmd {
331+ if d.State != OAuthStateDisplay {
332+ return nil
333+ }
334+ return tea.Sequence(
335+ tea.SetClipboard(d.userCode),
336+ uiutil.ReportInfo("Code copied to clipboard"),
337+ )
338+}
339+
340+func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
341+ if d.State != OAuthStateDisplay {
342+ return nil
343+ }
344+ return tea.Sequence(
345+ tea.SetClipboard(d.userCode),
346+ func() tea.Msg {
347+ if err := browser.OpenURL(d.verificationURL); err != nil {
348+ return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)}
349+ }
350+ return nil
351+ },
352+ uiutil.ReportInfo("Code copied and URL opened"),
353+ )
354+}
355+
356+func (m *OAuth) saveKeyAndContinue() Action {
357+ cfg := m.com.Config()
358+
359+ err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
360+ if err != nil {
361+ return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
362+ }
363+
364+ return ActionSelectModel{
365+ Provider: m.provider,
366+ Model: m.model,
367+ ModelType: m.modelType,
368+ }
369+}
internal/ui/dialog/oauth_copilot.gonew file+72
@@ -0,0 +1,72 @@
1+package dialog
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ tea "charm.land/bubbletea/v2"
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
10+ "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/oauth/copilot"
12+ "github.com/charmbracelet/crush/internal/ui/common"
13+)
14+
15+func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
16+ return newOAuth(com, provider, model, modelType, &OAuthCopilot{})
17+}
18+
19+type OAuthCopilot struct {
20+ deviceCode *copilot.DeviceCode
21+ cancelFunc func()
22+}
23+
24+var _ OAuthProvider = (*OAuthCopilot)(nil)
25+
26+func (m *OAuthCopilot) name() string {
27+ return "GitHub Copilot"
28+}
29+
30+func (m *OAuthCopilot) initiateAuth() tea.Msg {
31+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
32+ defer cancel()
33+
34+ deviceCode, err := copilot.RequestDeviceCode(ctx)
35+ if err != nil {
36+ return ActionOAuthErrored{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
37+ }
38+
39+ m.deviceCode = deviceCode
40+
41+ return ActionInitiateOAuth{
42+ DeviceCode: deviceCode.DeviceCode,
43+ UserCode: deviceCode.UserCode,
44+ VerificationURL: deviceCode.VerificationURI,
45+ ExpiresIn: deviceCode.ExpiresIn,
46+ Interval: deviceCode.Interval,
47+ }
48+}
49+
50+func (m *OAuthCopilot) startPolling(deviceCode string, expiresIn int) tea.Cmd {
51+ return func() tea.Msg {
52+ ctx, cancel := context.WithCancel(context.Background())
53+ m.cancelFunc = cancel
54+
55+ token, err := copilot.PollForToken(ctx, m.deviceCode)
56+ if err != nil {
57+ if ctx.Err() != nil {
58+ return nil // cancelled, don't report error.
59+ }
60+ return ActionOAuthErrored{Error: err}
61+ }
62+
63+ return ActionCompleteOAuth{Token: token}
64+ }
65+}
66+
67+func (m *OAuthCopilot) stopPolling() tea.Msg {
68+ if m.cancelFunc != nil {
69+ m.cancelFunc()
70+ }
71+ return nil
72+}
internal/ui/dialog/oauth_hyper.gonew file+90
@@ -0,0 +1,90 @@
1+package dialog
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ tea "charm.land/bubbletea/v2"
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
10+ "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/oauth/hyper"
12+ "github.com/charmbracelet/crush/internal/ui/common"
13+)
14+
15+func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
16+ return newOAuth(com, provider, model, modelType, &OAuthHyper{})
17+}
18+
19+type OAuthHyper struct {
20+ cancelFunc func()
21+}
22+
23+var _ OAuthProvider = (*OAuthHyper)(nil)
24+
25+func (m *OAuthHyper) name() string {
26+ return "Hyper"
27+}
28+
29+func (m *OAuthHyper) initiateAuth() tea.Msg {
30+ minimumWait := 750 * time.Millisecond
31+ startTime := time.Now()
32+
33+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
34+ defer cancel()
35+
36+ authResp, err := hyper.InitiateDeviceAuth(ctx)
37+
38+ ellapsed := time.Since(startTime)
39+ if ellapsed < minimumWait {
40+ time.Sleep(minimumWait - ellapsed)
41+ }
42+
43+ if err != nil {
44+ return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)}
45+ }
46+
47+ return ActionInitiateOAuth{
48+ DeviceCode: authResp.DeviceCode,
49+ UserCode: authResp.UserCode,
50+ ExpiresIn: authResp.ExpiresIn,
51+ VerificationURL: authResp.VerificationURL,
52+ }
53+}
54+
55+func (m *OAuthHyper) startPolling(deviceCode string, expiresIn int) tea.Cmd {
56+ return func() tea.Msg {
57+ ctx, cancel := context.WithCancel(context.Background())
58+ m.cancelFunc = cancel
59+
60+ refreshToken, err := hyper.PollForToken(ctx, deviceCode, expiresIn)
61+ if err != nil {
62+ if ctx.Err() != nil {
63+ return nil
64+ }
65+ return ActionOAuthErrored{err}
66+ }
67+
68+ token, err := hyper.ExchangeToken(ctx, refreshToken)
69+ if err != nil {
70+ return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)}
71+ }
72+
73+ introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
74+ if err != nil {
75+ return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)}
76+ }
77+ if !introspect.Active {
78+ return ActionOAuthErrored{fmt.Errorf("access token is not active")}
79+ }
80+
81+ return ActionCompleteOAuth{token}
82+ }
83+}
84+
85+func (m *OAuthHyper) stopPolling() tea.Msg {
86+ if m.cancelFunc != nil {
87+ m.cancelFunc()
88+ }
89+ return nil
90+}
internal/ui/model/ui.go+50−17
@@ -378,6 +378,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
378378 cmds = append(cmds, m.appendSessionMessage(msg.Payload))
379379 case pubsub.UpdatedEvent:
380380 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
381+ case pubsub.DeletedEvent:
382+ m.chat.RemoveMessage(msg.Payload.ID)
381383 }
382384 case pubsub.Event[history.File]:
383385 cmds = append(cmds, m.handleFileEvent(msg.Payload))
@@ -902,13 +904,24 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
902904 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
903905 break
904906 }
905− err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
906− if err != nil {
907− cmds = append(cmds, uiutil.ReportError(err))
908− }
907+ cmds = append(cmds, func() tea.Msg {
908+ err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
909+ if err != nil {
910+ return uiutil.ReportError(err)()
911+ }
912+ return nil
913+ })
914+ m.dialog.CloseDialog(dialog.CommandsID)
909915 case dialog.ActionToggleHelp:
910916 m.status.ToggleHelp()
911917 m.dialog.CloseDialog(dialog.CommandsID)
918+ case dialog.ActionExternalEditor:
919+ if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
920+ cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
921+ break
922+ }
923+ cmds = append(cmds, m.openEditor(m.textarea.Value()))
924+ m.dialog.CloseDialog(dialog.CommandsID)
912925 case dialog.ActionToggleCompactMode:
913926 cmds = append(cmds, m.toggleCompactMode())
914927 m.dialog.CloseDialog(dialog.CommandsID)
@@ -933,10 +946,20 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
933946 break
934947 }
935948
936− _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider)
937− if !isProviderConfigured {
949+ var (
950+ providerID = msg.Model.Provider
951+ isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
952+ isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
953+ )
954+
955+ // Attempt to import GitHub Copilot tokens from VSCode if available.
956+ if isCopilot && !isConfigured() {
957+ config.Get().ImportCopilot()
958+ }
959+
960+ if !isConfigured() {
938961 m.dialog.CloseDialog(dialog.ModelsID)
939− if cmd := m.openAPIKeyInputDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
962+ if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
940963 cmds = append(cmds, cmd)
941964 }
942965 break
@@ -952,6 +975,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
952975 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
953976 cmds = append(cmds, uiutil.ReportInfo(modelMsg))
954977 m.dialog.CloseDialog(dialog.APIKeyInputID)
978+ m.dialog.CloseDialog(dialog.OAuthID)
955979 m.dialog.CloseDialog(dialog.ModelsID)
956980 // TODO CHANGE
957981 case dialog.ActionPermissionResponse:
@@ -1027,19 +1051,28 @@ func substituteArgs(content string, args map[string]string) string {
10271051 return content
10281052}
10291053
1030−// openAPIKeyInputDialog opens the API key input dialog.
1031−func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1032− if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
1033− m.dialog.BringToFront(dialog.APIKeyInputID)
1034− return nil
1054+func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1055+ var (
1056+ dlg dialog.Dialog
1057+ cmd tea.Cmd
1058+ )
1059+
1060+ switch provider.ID {
1061+ case "hyper":
1062+ dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType)
1063+ case catwalk.InferenceProviderCopilot:
1064+ dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType)
1065+ default:
1066+ dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType)
10351067 }
10361068
1037− apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType)
1038− if err != nil {
1039− return uiutil.ReportError(err)
1069+ if m.dialog.ContainsDialog(dlg.ID()) {
1070+ m.dialog.BringToFront(dlg.ID())
1071+ return nil
10401072 }
1041− m.dialog.OpenDialog(apiKeyInputDialog)
1042− return nil
1073+
1074+ m.dialog.OpenDialog(dlg)
1075+ return cmd
10431076}
10441077
10451078func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
internal/ui/styles/styles.go+8−2
@@ -173,12 +173,14 @@ type Styles struct {
173173 FgSubtle color.Color
174174 Border color.Color
175175 BorderColor color.Color // Border focus color
176+ Error color.Color
176177 Warning color.Color
177178 Info color.Color
178179 White color.Color
179180 BlueLight color.Color
180181 Blue color.Color
181182 BlueDark color.Color
183+ GreenLight color.Color
182184 Green color.Color
183185 GreenDark color.Color
184186 Red color.Color
@@ -461,6 +463,7 @@ func DefaultStyles() Styles {
461463 borderFocus = charmtone.Charple
462464
463465 // Status
466+ error = charmtone.Sriracha
464467 warning = charmtone.Zest
465468 info = charmtone.Malibu
466469
@@ -475,8 +478,9 @@ func DefaultStyles() Styles {
475478 yellow = charmtone.Mustard
476479 // citron = charmtone.Citron
477480
478− green = charmtone.Julep
479− greenDark = charmtone.Guac
481+ greenLight = charmtone.Bok
482+ green = charmtone.Julep
483+ greenDark = charmtone.Guac
480484 // greenLight = charmtone.Bok
481485
482486 red = charmtone.Coral
@@ -507,12 +511,14 @@ func DefaultStyles() Styles {
507511 s.FgSubtle = fgSubtle
508512 s.Border = border
509513 s.BorderColor = borderFocus
514+ s.Error = error
510515 s.Warning = warning
511516 s.Info = info
512517 s.White = white
513518 s.BlueLight = blueLight
514519 s.Blue = blue
515520 s.BlueDark = blueDark
521+ s.GreenLight = greenLight
516522 s.Green = green
517523 s.GreenDark = greenDark
518524 s.Red = red