Merge branch 'ui' into ui-picker

Ayman Bagabas created

Change summary

internal/ui/dialog/actions.go       |  24 ++
internal/ui/dialog/api_key_input.go |   2 
internal/ui/dialog/models.go        |  12 
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             |  67 ++++-
internal/ui/styles/styles.go        |  10 
8 files changed, 623 insertions(+), 23 deletions(-)

Detailed changes

internal/ui/dialog/actions.go 🔗

@@ -11,6 +11,7 @@ import (
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
@@ -81,6 +82,29 @@ type (
 	}
 )
 
+// Messages for OAuth2 device flow dialog.
+type (
+	// ActionInitiateOAuth is sent when the device auth is initiated
+	// successfully.
+	ActionInitiateOAuth struct {
+		DeviceCode      string
+		UserCode        string
+		ExpiresIn       int
+		VerificationURL string
+		Interval        int
+	}
+
+	// ActionCompleteOAuth is sent when the device flow completes successfully.
+	ActionCompleteOAuth struct {
+		Token *oauth.Token
+	}
+
+	// ActionOAuthErrored is sent when the device flow encounters an error.
+	ActionOAuthErrored struct {
+		Error error
+	}
+)
+
 // ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
 // Bubble Tea program loop.
 type ActionCmd struct {

internal/ui/dialog/api_key_input.go 🔗

@@ -54,7 +54,7 @@ type APIKeyInput struct {
 var _ Dialog = (*APIKeyInput)(nil)
 
 // NewAPIKeyInput creates a new Models dialog.
-func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, error) {
+func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, tea.Cmd) {
 	t := com.Styles
 
 	m := APIKeyInput{}

internal/ui/dialog/models.go 🔗

@@ -454,10 +454,16 @@ func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) {
 	if err != nil {
 		return nil, fmt.Errorf("failed to get providers: %w", err)
 	}
-	filteredProviders := []catwalk.Provider{}
+	var filteredProviders []catwalk.Provider
 	for _, p := range providers {
-		hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
-		if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
+		var (
+			isAzure         = p.ID == catwalk.InferenceProviderAzure
+			isCopilot       = p.ID == catwalk.InferenceProviderCopilot
+			isHyper         = string(p.ID) == "hyper"
+			hasAPIKeyEnv    = strings.HasPrefix(p.APIKey, "$")
+			_, isConfigured = cfg.Providers.Get(string(p.ID))
+		)
+		if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured {
 			filteredProviders = append(filteredProviders, p)
 		}
 	}

internal/ui/dialog/oauth.go 🔗

@@ -0,0 +1,369 @@
+package dialog
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/spinner"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/uiutil"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/pkg/browser"
+)
+
+type OAuthProvider interface {
+	name() string
+	initiateAuth() tea.Msg
+	startPolling(deviceCode string, expiresIn int) tea.Cmd
+	stopPolling() tea.Msg
+}
+
+// OAuthState represents the current state of the device flow.
+type OAuthState int
+
+const (
+	OAuthStateInitializing OAuthState = iota
+	OAuthStateDisplay
+	OAuthStateSuccess
+	OAuthStateError
+)
+
+// OAuthID is the identifier for the model selection dialog.
+const OAuthID = "oauth"
+
+// OAuth handles the OAuth flow authentication.
+type OAuth struct {
+	com *common.Common
+
+	provider      catwalk.Provider
+	model         config.SelectedModel
+	modelType     config.SelectedModelType
+	oAuthProvider OAuthProvider
+
+	State OAuthState
+
+	spinner spinner.Model
+	help    help.Model
+	keyMap  struct {
+		Copy   key.Binding
+		Submit key.Binding
+		Close  key.Binding
+	}
+
+	width           int
+	deviceCode      string
+	userCode        string
+	verificationURL string
+	expiresIn       int
+	interval        int
+	token           *oauth.Token
+	cancelFunc      context.CancelFunc
+}
+
+var _ Dialog = (*OAuth)(nil)
+
+// newOAuth creates a new device flow component.
+func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, tea.Cmd) {
+	t := com.Styles
+
+	m := OAuth{}
+	m.com = com
+	m.provider = provider
+	m.model = model
+	m.modelType = modelType
+	m.oAuthProvider = oAuthProvider
+	m.width = 60
+	m.State = OAuthStateInitializing
+
+	m.spinner = spinner.New(
+		spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(t.Base.Foreground(t.GreenLight)),
+	)
+
+	m.help = help.New()
+	m.help.Styles = t.DialogHelpStyles()
+
+	m.keyMap.Copy = key.NewBinding(
+		key.WithKeys("c"),
+		key.WithHelp("c", "copy code"),
+	)
+	m.keyMap.Submit = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "copy & open"),
+	)
+	m.keyMap.Close = CloseKey
+
+	return &m, tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth)
+}
+
+// ID implements Dialog.
+func (m *OAuth) ID() string {
+	return OAuthID
+}
+
+// HandleMsg handles messages and state transitions.
+func (m *OAuth) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case spinner.TickMsg:
+		switch m.State {
+		case OAuthStateInitializing, OAuthStateDisplay:
+			var cmd tea.Cmd
+			m.spinner, cmd = m.spinner.Update(msg)
+			if cmd != nil {
+				return ActionCmd{cmd}
+			}
+		}
+
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.Copy):
+			cmd := m.copyCode()
+			return ActionCmd{cmd}
+
+		case key.Matches(msg, m.keyMap.Submit):
+			switch m.State {
+			case OAuthStateSuccess:
+				return m.saveKeyAndContinue()
+
+			default:
+				cmd := m.copyCodeAndOpenURL()
+				return ActionCmd{cmd}
+			}
+
+		case key.Matches(msg, m.keyMap.Close):
+			switch m.State {
+			case OAuthStateSuccess:
+				return m.saveKeyAndContinue()
+
+			default:
+				return ActionClose{}
+			}
+		}
+
+	case ActionInitiateOAuth:
+		m.deviceCode = msg.DeviceCode
+		m.userCode = msg.UserCode
+		m.expiresIn = msg.ExpiresIn
+		m.verificationURL = msg.VerificationURL
+		m.interval = msg.Interval
+		m.State = OAuthStateDisplay
+		return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)}
+
+	case ActionCompleteOAuth:
+		m.State = OAuthStateSuccess
+		m.token = msg.Token
+		return ActionCmd{m.oAuthProvider.stopPolling}
+
+	case ActionOAuthErrored:
+		m.State = OAuthStateError
+		cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error))
+		return ActionCmd{cmd}
+	}
+	return nil
+}
+
+// View renders the device flow dialog.
+func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	var (
+		t           = m.com.Styles
+		dialogStyle = t.Dialog.View.Width(m.width)
+		view        = dialogStyle.Render(m.dialogContent())
+	)
+	DrawCenterCursor(scr, area, view, nil)
+	return nil
+}
+
+func (m *OAuth) dialogContent() string {
+	var (
+		t         = m.com.Styles
+		helpStyle = t.Dialog.HelpView
+	)
+
+	switch m.State {
+	case OAuthStateInitializing:
+		return m.innerDialogContent()
+
+	default:
+		elements := []string{
+			m.headerContent(),
+			m.innerDialogContent(),
+			helpStyle.Render(m.help.View(m)),
+		}
+		return strings.Join(elements, "\n")
+	}
+}
+
+func (m *OAuth) headerContent() string {
+	var (
+		t            = m.com.Styles
+		titleStyle   = t.Dialog.Title
+		dialogStyle  = t.Dialog.View.Width(m.width)
+		headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
+	)
+	return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset)
+}
+
+func (m *OAuth) innerDialogContent() string {
+	var (
+		t            = m.com.Styles
+		whiteStyle   = lipgloss.NewStyle().Foreground(t.White)
+		primaryStyle = lipgloss.NewStyle().Foreground(t.Primary)
+		greenStyle   = lipgloss.NewStyle().Foreground(t.GreenLight)
+		linkStyle    = lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
+		errorStyle   = lipgloss.NewStyle().Foreground(t.Error)
+		mutedStyle   = lipgloss.NewStyle().Foreground(t.FgMuted)
+	)
+
+	switch m.State {
+	case OAuthStateInitializing:
+		return lipgloss.NewStyle().
+			Margin(1, 1).
+			Width(m.width - 2).
+			Align(lipgloss.Center).
+			Render(
+				greenStyle.Render(m.spinner.View()) +
+					mutedStyle.Render("Initializing..."),
+			)
+
+	case OAuthStateDisplay:
+		instructions := lipgloss.NewStyle().
+			Margin(0, 1).
+			Width(m.width - 2).
+			Render(
+				whiteStyle.Render("Press ") +
+					primaryStyle.Render("enter") +
+					whiteStyle.Render(" to copy the code below and open the browser."),
+			)
+
+		codeBox := lipgloss.NewStyle().
+			Width(m.width-2).
+			Height(7).
+			Align(lipgloss.Center, lipgloss.Center).
+			Background(t.BgBaseLighter).
+			Margin(0, 1).
+			Render(
+				lipgloss.NewStyle().
+					Bold(true).
+					Foreground(t.White).
+					Render(m.userCode),
+			)
+
+		link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL)
+		url := mutedStyle.
+			Margin(0, 1).
+			Width(m.width - 2).
+			Render("Browser not opening? Refer to\n" + link)
+
+		waiting := lipgloss.NewStyle().
+			Margin(0, 1).
+			Width(m.width - 2).
+			Render(
+				greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."),
+			)
+
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			"",
+			instructions,
+			"",
+			codeBox,
+			"",
+			url,
+			"",
+			waiting,
+			"",
+		)
+
+	case OAuthStateSuccess:
+		return greenStyle.
+			Margin(1).
+			Width(m.width - 2).
+			Render("Authentication successful!")
+
+	case OAuthStateError:
+		return lipgloss.NewStyle().
+			Margin(1).
+			Width(m.width - 2).
+			Render(errorStyle.Render("Authentication failed."))
+
+	default:
+		return ""
+	}
+}
+
+// FullHelp returns the full help view.
+func (m *OAuth) FullHelp() [][]key.Binding {
+	return [][]key.Binding{m.ShortHelp()}
+}
+
+// ShortHelp returns the full help view.
+func (m *OAuth) ShortHelp() []key.Binding {
+	switch m.State {
+	case OAuthStateError:
+		return []key.Binding{m.keyMap.Close}
+
+	case OAuthStateSuccess:
+		return []key.Binding{
+			key.NewBinding(
+				key.WithKeys("finish", "ctrl+y", "esc"),
+				key.WithHelp("enter", "finish"),
+			),
+		}
+
+	default:
+		return []key.Binding{
+			m.keyMap.Copy,
+			m.keyMap.Submit,
+			m.keyMap.Close,
+		}
+	}
+}
+
+func (d *OAuth) copyCode() tea.Cmd {
+	if d.State != OAuthStateDisplay {
+		return nil
+	}
+	return tea.Sequence(
+		tea.SetClipboard(d.userCode),
+		uiutil.ReportInfo("Code copied to clipboard"),
+	)
+}
+
+func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
+	if d.State != OAuthStateDisplay {
+		return nil
+	}
+	return tea.Sequence(
+		tea.SetClipboard(d.userCode),
+		func() tea.Msg {
+			if err := browser.OpenURL(d.verificationURL); err != nil {
+				return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)}
+			}
+			return nil
+		},
+		uiutil.ReportInfo("Code copied and URL opened"),
+	)
+}
+
+func (m *OAuth) saveKeyAndContinue() Action {
+	cfg := m.com.Config()
+
+	err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
+	if err != nil {
+		return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
+	}
+
+	return ActionSelectModel{
+		Provider:  m.provider,
+		Model:     m.model,
+		ModelType: m.modelType,
+	}
+}

internal/ui/dialog/oauth_copilot.go 🔗

@@ -0,0 +1,72 @@
+package dialog
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth/copilot"
+	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
+	return newOAuth(com, provider, model, modelType, &OAuthCopilot{})
+}
+
+type OAuthCopilot struct {
+	deviceCode *copilot.DeviceCode
+	cancelFunc func()
+}
+
+var _ OAuthProvider = (*OAuthCopilot)(nil)
+
+func (m *OAuthCopilot) name() string {
+	return "GitHub Copilot"
+}
+
+func (m *OAuthCopilot) initiateAuth() tea.Msg {
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	deviceCode, err := copilot.RequestDeviceCode(ctx)
+	if err != nil {
+		return ActionOAuthErrored{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
+	}
+
+	m.deviceCode = deviceCode
+
+	return ActionInitiateOAuth{
+		DeviceCode:      deviceCode.DeviceCode,
+		UserCode:        deviceCode.UserCode,
+		VerificationURL: deviceCode.VerificationURI,
+		ExpiresIn:       deviceCode.ExpiresIn,
+		Interval:        deviceCode.Interval,
+	}
+}
+
+func (m *OAuthCopilot) startPolling(deviceCode string, expiresIn int) tea.Cmd {
+	return func() tea.Msg {
+		ctx, cancel := context.WithCancel(context.Background())
+		m.cancelFunc = cancel
+
+		token, err := copilot.PollForToken(ctx, m.deviceCode)
+		if err != nil {
+			if ctx.Err() != nil {
+				return nil // cancelled, don't report error.
+			}
+			return ActionOAuthErrored{Error: err}
+		}
+
+		return ActionCompleteOAuth{Token: token}
+	}
+}
+
+func (m *OAuthCopilot) stopPolling() tea.Msg {
+	if m.cancelFunc != nil {
+		m.cancelFunc()
+	}
+	return nil
+}

internal/ui/dialog/oauth_hyper.go 🔗

@@ -0,0 +1,90 @@
+package dialog
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth/hyper"
+	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
+	return newOAuth(com, provider, model, modelType, &OAuthHyper{})
+}
+
+type OAuthHyper struct {
+	cancelFunc func()
+}
+
+var _ OAuthProvider = (*OAuthHyper)(nil)
+
+func (m *OAuthHyper) name() string {
+	return "Hyper"
+}
+
+func (m *OAuthHyper) initiateAuth() tea.Msg {
+	minimumWait := 750 * time.Millisecond
+	startTime := time.Now()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	authResp, err := hyper.InitiateDeviceAuth(ctx)
+
+	ellapsed := time.Since(startTime)
+	if ellapsed < minimumWait {
+		time.Sleep(minimumWait - ellapsed)
+	}
+
+	if err != nil {
+		return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)}
+	}
+
+	return ActionInitiateOAuth{
+		DeviceCode:      authResp.DeviceCode,
+		UserCode:        authResp.UserCode,
+		ExpiresIn:       authResp.ExpiresIn,
+		VerificationURL: authResp.VerificationURL,
+	}
+}
+
+func (m *OAuthHyper) startPolling(deviceCode string, expiresIn int) tea.Cmd {
+	return func() tea.Msg {
+		ctx, cancel := context.WithCancel(context.Background())
+		m.cancelFunc = cancel
+
+		refreshToken, err := hyper.PollForToken(ctx, deviceCode, expiresIn)
+		if err != nil {
+			if ctx.Err() != nil {
+				return nil
+			}
+			return ActionOAuthErrored{err}
+		}
+
+		token, err := hyper.ExchangeToken(ctx, refreshToken)
+		if err != nil {
+			return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)}
+		}
+
+		introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
+		if err != nil {
+			return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)}
+		}
+		if !introspect.Active {
+			return ActionOAuthErrored{fmt.Errorf("access token is not active")}
+		}
+
+		return ActionCompleteOAuth{token}
+	}
+}
+
+func (m *OAuthHyper) stopPolling() tea.Msg {
+	if m.cancelFunc != nil {
+		m.cancelFunc()
+	}
+	return nil
+}

internal/ui/model/ui.go 🔗

@@ -378,6 +378,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, m.appendSessionMessage(msg.Payload))
 		case pubsub.UpdatedEvent:
 			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
+		case pubsub.DeletedEvent:
+			m.chat.RemoveMessage(msg.Payload.ID)
 		}
 	case pubsub.Event[history.File]:
 		cmds = append(cmds, m.handleFileEvent(msg.Payload))
@@ -902,13 +904,24 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
 			break
 		}
-		err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
-		if err != nil {
-			cmds = append(cmds, uiutil.ReportError(err))
-		}
+		cmds = append(cmds, func() tea.Msg {
+			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
+			if err != nil {
+				return uiutil.ReportError(err)()
+			}
+			return nil
+		})
+		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionToggleHelp:
 		m.status.ToggleHelp()
 		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionExternalEditor:
+		if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
+			break
+		}
+		cmds = append(cmds, m.openEditor(m.textarea.Value()))
+		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionToggleCompactMode:
 		cmds = append(cmds, m.toggleCompactMode())
 		m.dialog.CloseDialog(dialog.CommandsID)
@@ -933,10 +946,20 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			break
 		}
 
-		_, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider)
-		if !isProviderConfigured {
+		var (
+			providerID   = msg.Model.Provider
+			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
+			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
+		)
+
+		// Attempt to import GitHub Copilot tokens from VSCode if available.
+		if isCopilot && !isConfigured() {
+			config.Get().ImportCopilot()
+		}
+
+		if !isConfigured() {
 			m.dialog.CloseDialog(dialog.ModelsID)
-			if cmd := m.openAPIKeyInputDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
+			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
 				cmds = append(cmds, cmd)
 			}
 			break
@@ -952,6 +975,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
 		cmds = append(cmds, uiutil.ReportInfo(modelMsg))
 		m.dialog.CloseDialog(dialog.APIKeyInputID)
+		m.dialog.CloseDialog(dialog.OAuthID)
 		m.dialog.CloseDialog(dialog.ModelsID)
 		// TODO CHANGE
 	case dialog.ActionPermissionResponse:
@@ -1027,19 +1051,28 @@ func substituteArgs(content string, args map[string]string) string {
 	return content
 }
 
-// openAPIKeyInputDialog opens the API key input dialog.
-func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
-	if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
-		m.dialog.BringToFront(dialog.APIKeyInputID)
-		return nil
+func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
+	var (
+		dlg dialog.Dialog
+		cmd tea.Cmd
+	)
+
+	switch provider.ID {
+	case "hyper":
+		dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType)
+	case catwalk.InferenceProviderCopilot:
+		dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType)
+	default:
+		dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType)
 	}
 
-	apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType)
-	if err != nil {
-		return uiutil.ReportError(err)
+	if m.dialog.ContainsDialog(dlg.ID()) {
+		m.dialog.BringToFront(dlg.ID())
+		return nil
 	}
-	m.dialog.OpenDialog(apiKeyInputDialog)
-	return nil
+
+	m.dialog.OpenDialog(dlg)
+	return cmd
 }
 
 func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {

internal/ui/styles/styles.go 🔗

@@ -173,12 +173,14 @@ type Styles struct {
 	FgSubtle      color.Color
 	Border        color.Color
 	BorderColor   color.Color // Border focus color
+	Error         color.Color
 	Warning       color.Color
 	Info          color.Color
 	White         color.Color
 	BlueLight     color.Color
 	Blue          color.Color
 	BlueDark      color.Color
+	GreenLight    color.Color
 	Green         color.Color
 	GreenDark     color.Color
 	Red           color.Color
@@ -461,6 +463,7 @@ func DefaultStyles() Styles {
 		borderFocus = charmtone.Charple
 
 		// Status
+		error   = charmtone.Sriracha
 		warning = charmtone.Zest
 		info    = charmtone.Malibu
 
@@ -475,8 +478,9 @@ func DefaultStyles() Styles {
 		yellow = charmtone.Mustard
 		// citron = charmtone.Citron
 
-		green     = charmtone.Julep
-		greenDark = charmtone.Guac
+		greenLight = charmtone.Bok
+		green      = charmtone.Julep
+		greenDark  = charmtone.Guac
 		// greenLight = charmtone.Bok
 
 		red     = charmtone.Coral
@@ -507,12 +511,14 @@ func DefaultStyles() Styles {
 	s.FgSubtle = fgSubtle
 	s.Border = border
 	s.BorderColor = borderFocus
+	s.Error = error
 	s.Warning = warning
 	s.Info = info
 	s.White = white
 	s.BlueLight = blueLight
 	s.Blue = blue
 	s.BlueDark = blueDark
+	s.GreenLight = greenLight
 	s.Green = green
 	s.GreenDark = greenDark
 	s.Red = red