on main help sign in
Merge branch 'ui' into ui-picker
be46953Ayman Bagabas
internal/ui/dialog/actions.go+24
@@ -11,6 +11,7 @@ import (
11 "github.com/charmbracelet/crush/internal/commands"
11 "github.com/charmbracelet/crush/internal/commands"
12 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/message"
14+ "github.com/charmbracelet/crush/internal/oauth"
14 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/session"
16 "github.com/charmbracelet/crush/internal/session"
16 "github.com/charmbracelet/crush/internal/ui/common"
17 "github.com/charmbracelet/crush/internal/ui/common"
@@ -81,6 +82,29 @@ type (
81 }
82 }
82)
83)
83
84
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+
84// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
108// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
85// Bubble Tea program loop.
109// Bubble Tea program loop.
86type ActionCmd struct {
110type ActionCmd struct {
internal/ui/dialog/api_key_input.go+1−1
@@ -54,7 +54,7 @@ type APIKeyInput struct {
54var _ Dialog = (*APIKeyInput)(nil)
54var _ Dialog = (*APIKeyInput)(nil)
55
55
56// NewAPIKeyInput creates a new Models dialog.
56// 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) {
58 t := com.Styles
58 t := com.Styles
59
59
60 m := APIKeyInput{}
60 m := APIKeyInput{}
internal/ui/dialog/models.go+9−3
@@ -454,10 +454,16 @@ func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) {
454 if err != nil {
454 if err != nil {
455 return nil, fmt.Errorf("failed to get providers: %w", err)
455 return nil, fmt.Errorf("failed to get providers: %w", err)
456 }
456 }
457− filteredProviders := []catwalk.Provider{}
457+ var filteredProviders []catwalk.Provider
458 for _, p := range providers {
458 for _, p := range providers {
459+ var (
460+ isAzure = p.ID == catwalk.InferenceProviderAzure
461+ isCopilot = p.ID == catwalk.InferenceProviderCopilot
462+ isHyper = string(p.ID) == "hyper"
459− hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
463+ hasAPIKeyEnv = strings.HasPrefix(p.APIKey, "$")
460− if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
464+ _, isConfigured = cfg.Providers.Get(string(p.ID))
465+ )
466+ if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured {
461 filteredProviders = append(filteredProviders, p)
467 filteredProviders = append(filteredProviders, p)
462 }
468 }
463 }
469 }
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) {
378 cmds = append(cmds, m.appendSessionMessage(msg.Payload))
378 cmds = append(cmds, m.appendSessionMessage(msg.Payload))
379 case pubsub.UpdatedEvent:
379 case pubsub.UpdatedEvent:
380 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
380 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
381+ case pubsub.DeletedEvent:
382+ m.chat.RemoveMessage(msg.Payload.ID)
381 }
383 }
382 case pubsub.Event[history.File]:
384 case pubsub.Event[history.File]:
383 cmds = append(cmds, m.handleFileEvent(msg.Payload))
385 cmds = append(cmds, m.handleFileEvent(msg.Payload))
@@ -902,13 +904,24 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
902 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
904 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
903 break
905 break
904 }
906 }
907+ cmds = append(cmds, func() tea.Msg {
905− err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
908+ err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
906− if err != nil {
909+ if err != nil {
907− cmds = append(cmds, uiutil.ReportError(err))
910+ return uiutil.ReportError(err)()
911+ }
912+ return nil
908− }
913+ })
914+ m.dialog.CloseDialog(dialog.CommandsID)
909 case dialog.ActionToggleHelp:
915 case dialog.ActionToggleHelp:
910 m.status.ToggleHelp()
916 m.status.ToggleHelp()
911 m.dialog.CloseDialog(dialog.CommandsID)
917 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)
912 case dialog.ActionToggleCompactMode:
925 case dialog.ActionToggleCompactMode:
913 cmds = append(cmds, m.toggleCompactMode())
926 cmds = append(cmds, m.toggleCompactMode())
914 m.dialog.CloseDialog(dialog.CommandsID)
927 m.dialog.CloseDialog(dialog.CommandsID)
@@ -933,10 +946,20 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
933 break
946 break
934 }
947 }
935
948
936− _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider)
949+ var (
937− if !isProviderConfigured {
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() {
938 m.dialog.CloseDialog(dialog.ModelsID)
961 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 {
940 cmds = append(cmds, cmd)
963 cmds = append(cmds, cmd)
941 }
964 }
942 break
965 break
@@ -952,6 +975,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
952 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
975 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
953 cmds = append(cmds, uiutil.ReportInfo(modelMsg))
976 cmds = append(cmds, uiutil.ReportInfo(modelMsg))
954 m.dialog.CloseDialog(dialog.APIKeyInputID)
977 m.dialog.CloseDialog(dialog.APIKeyInputID)
978+ m.dialog.CloseDialog(dialog.OAuthID)
955 m.dialog.CloseDialog(dialog.ModelsID)
979 m.dialog.CloseDialog(dialog.ModelsID)
956 // TODO CHANGE
980 // TODO CHANGE
957 case dialog.ActionPermissionResponse:
981 case dialog.ActionPermissionResponse:
@@ -1027,19 +1051,28 @@ func substituteArgs(content string, args map[string]string) string {
1027 return content
1051 return content
1028}
1052}
1029
1053
1030−// openAPIKeyInputDialog opens the API key input dialog.
1031−func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1054+func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1032− if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
1055+ var (
1033− m.dialog.BringToFront(dialog.APIKeyInputID)
1056+ dlg dialog.Dialog
1034− return nil
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)
1035 }
1067 }
1036
1068
1037− apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType)
1069+ if m.dialog.ContainsDialog(dlg.ID()) {
1038− if err != nil {
1070+ m.dialog.BringToFront(dlg.ID())
1039− return uiutil.ReportError(err)
1071+ return nil
1040 }
1072 }
1073+
1041− m.dialog.OpenDialog(apiKeyInputDialog)
1074+ m.dialog.OpenDialog(dlg)
1042− return nil
1075+ return cmd
1043}
1076}
1044
1077
1045func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1078func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
internal/ui/styles/styles.go+8−2
@@ -173,12 +173,14 @@ type Styles struct {
173 FgSubtle color.Color
173 FgSubtle color.Color
174 Border color.Color
174 Border color.Color
175 BorderColor color.Color // Border focus color
175 BorderColor color.Color // Border focus color
176+ Error color.Color
176 Warning color.Color
177 Warning color.Color
177 Info color.Color
178 Info color.Color
178 White color.Color
179 White color.Color
179 BlueLight color.Color
180 BlueLight color.Color
180 Blue color.Color
181 Blue color.Color
181 BlueDark color.Color
182 BlueDark color.Color
183+ GreenLight color.Color
182 Green color.Color
184 Green color.Color
183 GreenDark color.Color
185 GreenDark color.Color
184 Red color.Color
186 Red color.Color
@@ -461,6 +463,7 @@ func DefaultStyles() Styles {
461 borderFocus = charmtone.Charple
463 borderFocus = charmtone.Charple
462
464
463 // Status
465 // Status
466+ error = charmtone.Sriracha
464 warning = charmtone.Zest
467 warning = charmtone.Zest
465 info = charmtone.Malibu
468 info = charmtone.Malibu
466
469
@@ -475,8 +478,9 @@ func DefaultStyles() Styles {
475 yellow = charmtone.Mustard
478 yellow = charmtone.Mustard
476 // citron = charmtone.Citron
479 // citron = charmtone.Citron
477
480
481+ greenLight = charmtone.Bok
478− green = charmtone.Julep
482+ green = charmtone.Julep
479− greenDark = charmtone.Guac
483+ greenDark = charmtone.Guac
480 // greenLight = charmtone.Bok
484 // greenLight = charmtone.Bok
481
485
482 red = charmtone.Coral
486 red = charmtone.Coral
@@ -507,12 +511,14 @@ func DefaultStyles() Styles {
507 s.FgSubtle = fgSubtle
511 s.FgSubtle = fgSubtle
508 s.Border = border
512 s.Border = border
509 s.BorderColor = borderFocus
513 s.BorderColor = borderFocus
514+ s.Error = error
510 s.Warning = warning
515 s.Warning = warning
511 s.Info = info
516 s.Info = info
512 s.White = white
517 s.White = white
513 s.BlueLight = blueLight
518 s.BlueLight = blueLight
514 s.Blue = blue
519 s.Blue = blue
515 s.BlueDark = blueDark
520 s.BlueDark = blueDark
521+ s.GreenLight = greenLight
516 s.Green = green
522 s.Green = green
517 s.GreenDark = greenDark
523 s.GreenDark = greenDark
518 s.Red = red
524 s.Red = red