From f4b848b50db54d17cfb162d0b8ba80109ac67d5a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 15 Jan 2026 15:42:50 +0100 Subject: [PATCH 01/10] chore: implement missing command and fix summarize (#1882) --- internal/ui/model/ui.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index c3c6edebbccab27b1072b9376e3c5169d3137894..b580c4d792eb60a571af5919ee582426279e0c87 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -375,6 +375,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)) @@ -887,13 +889,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) From f8a9b943e7069b76518c10b7db50694c5dd6345c Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 11:26:07 -0300 Subject: [PATCH 02/10] fix: ensure that hyper and copilot models show up even if not configured --- internal/ui/dialog/models.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index c12c78e1f4753c01f80653bb6ee5e5013fc9ea09..2a40b8135f9041dd505672490d964257d57bc343 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -459,10 +459,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) } } From c45b1521640ccc513507ae0815609e7c031941d0 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 13 Jan 2026 17:41:34 -0300 Subject: [PATCH 03/10] feat: implement hyper oauth flow in the new ui codebase --- internal/ui/dialog/actions.go | 23 ++ internal/ui/dialog/oauth.go | 423 ++++++++++++++++++++++++++++++++++ internal/ui/model/ui.go | 30 ++- internal/ui/styles/styles.go | 10 +- 4 files changed, 482 insertions(+), 4 deletions(-) create mode 100644 internal/ui/dialog/oauth.go diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 81911f9919be6c94ac158052b4a4e9b2236342a0..0048c4ac06540e22175d4663e1ee9123e1eba211 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -5,6 +5,7 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" ) @@ -73,6 +74,28 @@ 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 + } + + // 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 { diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go new file mode 100644 index 0000000000000000000000000000000000000000..68f22a76e037278d5884f44c9cfbdeea2f3549f9 --- /dev/null +++ b/internal/ui/dialog/oauth.go @@ -0,0 +1,423 @@ +package dialog + +import ( + "context" + "fmt" + "strings" + "time" + + "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/oauth/hyper" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" + "github.com/pkg/browser" +) + +// 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 + + 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 + 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) (*OAuth, error) { + t := com.Styles + + m := OAuth{} + m.com = com + m.provider = provider + m.model = model + m.modelType = modelType + 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, nil +} + +// ID implements Dialog. +func (m *OAuth) ID() string { + return OAuthID +} + +// Init implements Dialog. +func (m *OAuth) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, m.initiateDeviceAuth) +} + +// 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.State = OAuthStateDisplay + return ActionCmd{m.startPolling(msg.DeviceCode)} + + case ActionCompleteOAuth: + m.State = OAuthStateSuccess + m.token = msg.Token + return ActionCmd{m.stopPolling} + + case ActionOAuthErrored: + m.State = OAuthStateError + cmd := tea.Batch(m.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 Hyper"), 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(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(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 := greenStyle. + Width(m.width - 2). + Margin(1). + Render(m.spinner.View() + "Verifying...") + + return lipgloss.JoinVertical( + lipgloss.Left, + instructions, + codeBox, + url, + waiting, + ) + + case OAuthStateSuccess: + return greenStyle. + Margin(1). + Width(m.width - 2). + Align(lipgloss.Center). + 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, + } +} + +func (m *OAuth) initiateDeviceAuth() 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, + } +} + +// startPolling starts polling for the device token. +func (m *OAuth) startPolling(deviceCode string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelFunc = cancel + + refreshToken, err := hyper.PollForToken(ctx, deviceCode, m.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 *OAuth) stopPolling() tea.Msg { + if m.cancelFunc != nil { + m.cancelFunc() + } + return nil +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index b580c4d792eb60a571af5919ee582426279e0c87..a61c3050ac7f982c0141b8525b304c92e379b5bc 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -934,7 +934,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider) if !isProviderConfigured { 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 @@ -950,6 +950,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: @@ -1016,7 +1017,17 @@ func substituteArgs(content string, args map[string]string) string { return content } -// openAPIKeyInputDialog opens the API key input dialog. +func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { + switch provider.ID { + case "hyper": + return m.openOAuthDialog(provider, model, modelType) + case catwalk.InferenceProviderCopilot: + return m.openOAuthDialog(provider, model, modelType) + default: + return m.openAPIKeyInputDialog(provider, model, modelType) + } +} + 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) @@ -1031,6 +1042,21 @@ func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.Selec return nil } +func (m *UI) openOAuthDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { + if m.dialog.ContainsDialog(dialog.OAuthID) { + m.dialog.BringToFront(dialog.OAuthID) + return nil + } + + oAuthDialog, err := dialog.NewOAuth(m.com, provider, model, modelType) + if err != nil { + return uiutil.ReportError(err) + } + m.dialog.OpenDialog(oAuthDialog) + + return oAuthDialog.Init() +} + func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { var cmds []tea.Cmd diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 878ed83eaf7c0eaaa490dc11546a72f0a9a8a539..bb39cc0a583cbaa834c4c59e139d97cc72a2de76 100644 --- a/internal/ui/styles/styles.go +++ b/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 @@ -459,6 +461,7 @@ func DefaultStyles() Styles { borderFocus = charmtone.Charple // Status + error = charmtone.Sriracha warning = charmtone.Zest info = charmtone.Malibu @@ -473,8 +476,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 @@ -505,12 +509,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 From ccb1a643e9b0959e19bd0263e493a8b7a8544972 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 14 Jan 2026 18:09:02 -0300 Subject: [PATCH 04/10] refactor: make oauth dialog generic and move provider logic to interface --- internal/ui/dialog/oauth.go | 95 +++++++------------------------ internal/ui/dialog/oauth_hyper.go | 90 +++++++++++++++++++++++++++++ internal/ui/model/ui.go | 12 ++-- 3 files changed, 117 insertions(+), 80 deletions(-) create mode 100644 internal/ui/dialog/oauth_hyper.go diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 68f22a76e037278d5884f44c9cfbdeea2f3549f9..f87a583be4160edf1d6a1e42de29f7dd01a513bc 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" @@ -14,13 +13,19 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/oauth" - "github.com/charmbracelet/crush/internal/oauth/hyper" "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 @@ -38,9 +43,10 @@ const OAuthID = "oauth" type OAuth struct { com *common.Common - provider catwalk.Provider - model config.SelectedModel - modelType config.SelectedModelType + provider catwalk.Provider + model config.SelectedModel + modelType config.SelectedModelType + oAuthProvider OAuthProvider State OAuthState @@ -63,8 +69,8 @@ type OAuth struct { 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) (*OAuth, error) { +// newOAuth creates a new device flow component. +func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, error) { t := com.Styles m := OAuth{} @@ -72,6 +78,7 @@ func NewOAuth(com *common.Common, provider catwalk.Provider, model config.Select m.provider = provider m.model = model m.modelType = modelType + m.oAuthProvider = oAuthProvider m.width = 60 m.State = OAuthStateInitializing @@ -103,7 +110,7 @@ func (m *OAuth) ID() string { // Init implements Dialog. func (m *OAuth) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, m.initiateDeviceAuth) + return tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth) } // HandleMsg handles messages and state transitions. @@ -151,16 +158,16 @@ func (m *OAuth) HandleMsg(msg tea.Msg) Action { m.expiresIn = msg.ExpiresIn m.verificationURL = msg.VerificationURL m.State = OAuthStateDisplay - return ActionCmd{m.startPolling(msg.DeviceCode)} + return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)} case ActionCompleteOAuth: m.State = OAuthStateSuccess m.token = msg.Token - return ActionCmd{m.stopPolling} + return ActionCmd{m.oAuthProvider.stopPolling} case ActionOAuthErrored: m.State = OAuthStateError - cmd := tea.Batch(m.stopPolling, uiutil.ReportError(msg.Error)) + cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error)) return ActionCmd{cmd} } return nil @@ -204,7 +211,7 @@ func (m *OAuth) headerContent() string { dialogStyle = t.Dialog.View.Width(m.width) headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() ) - return common.DialogTitle(t, titleStyle.Render("Authenticate with Hyper"), m.width-headerOffset) + return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset) } func (m *OAuth) innerDialogContent() string { @@ -357,67 +364,3 @@ func (m *OAuth) saveKeyAndContinue() Action { ModelType: m.modelType, } } - -func (m *OAuth) initiateDeviceAuth() 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, - } -} - -// startPolling starts polling for the device token. -func (m *OAuth) startPolling(deviceCode string) tea.Cmd { - return func() tea.Msg { - ctx, cancel := context.WithCancel(context.Background()) - m.cancelFunc = cancel - - refreshToken, err := hyper.PollForToken(ctx, deviceCode, m.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 *OAuth) stopPolling() tea.Msg { - if m.cancelFunc != nil { - m.cancelFunc() - } - return nil -} diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go new file mode 100644 index 0000000000000000000000000000000000000000..65d3e34e817384e8e123e4720f7cdd310213aa87 --- /dev/null +++ b/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, error) { + 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 +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a61c3050ac7f982c0141b8525b304c92e379b5bc..e9bd2fd3cedb5dd93bb5e1bfe31e2fb2b6f65f72 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1020,9 +1020,9 @@ func substituteArgs(content string, args map[string]string) string { func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { switch provider.ID { case "hyper": - return m.openOAuthDialog(provider, model, modelType) + return m.openOAuthHyperDialog(provider, model, modelType) case catwalk.InferenceProviderCopilot: - return m.openOAuthDialog(provider, model, modelType) + return m.openOAuthCopilotDialog(provider, model, modelType) default: return m.openAPIKeyInputDialog(provider, model, modelType) } @@ -1042,13 +1042,13 @@ func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.Selec return nil } -func (m *UI) openOAuthDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { +func (m *UI) openOAuthHyperDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { if m.dialog.ContainsDialog(dialog.OAuthID) { m.dialog.BringToFront(dialog.OAuthID) return nil } - oAuthDialog, err := dialog.NewOAuth(m.com, provider, model, modelType) + oAuthDialog, err := dialog.NewOAuthHyper(m.com, provider, model, modelType) if err != nil { return uiutil.ReportError(err) } @@ -1057,6 +1057,10 @@ func (m *UI) openOAuthDialog(provider catwalk.Provider, model config.SelectedMod return oAuthDialog.Init() } +func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { + panic("TODO") +} + func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { var cmds []tea.Cmd From f2f44540f58e3cca3602f11d5bd7c002e3ff3109 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 14:23:45 -0300 Subject: [PATCH 05/10] feat: implement github copilot oauth flow in the new ui codebase --- internal/ui/dialog/actions.go | 1 + internal/ui/dialog/oauth.go | 2 + internal/ui/dialog/oauth_copilot.go | 72 +++++++++++++++++++++++++++++ internal/ui/model/ui.go | 27 +++++++++-- 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 internal/ui/dialog/oauth_copilot.go diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 0048c4ac06540e22175d4663e1ee9123e1eba211..1c7e9c2cdd9338cac4f28aee0d87ec7c08f5fa15 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -83,6 +83,7 @@ type ( UserCode string ExpiresIn int VerificationURL string + Interval int } // ActionCompleteOAuth is sent when the device flow completes successfully. diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index f87a583be4160edf1d6a1e42de29f7dd01a513bc..1268d8a2324fff56843e8468cb2d4d3cd66895af 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -63,6 +63,7 @@ type OAuth struct { userCode string verificationURL string expiresIn int + interval int token *oauth.Token cancelFunc context.CancelFunc } @@ -157,6 +158,7 @@ func (m *OAuth) HandleMsg(msg tea.Msg) Action { 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)} diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go new file mode 100644 index 0000000000000000000000000000000000000000..2364c02804ffc0eca776b24849bdb38aacf1df94 --- /dev/null +++ b/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, error) { + 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 +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e9bd2fd3cedb5dd93bb5e1bfe31e2fb2b6f65f72..a79598bd857688491b57c58d85244e2499408498 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -931,8 +931,18 @@ 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.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { cmds = append(cmds, cmd) @@ -1058,7 +1068,18 @@ func (m *UI) openOAuthHyperDialog(provider catwalk.Provider, model config.Select } func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { - panic("TODO") + if m.dialog.ContainsDialog(dialog.OAuthID) { + m.dialog.BringToFront(dialog.OAuthID) + return nil + } + + oAuthDialog, err := dialog.NewOAuthCopilot(m.com, provider, model, modelType) + if err != nil { + return uiutil.ReportError(err) + } + m.dialog.OpenDialog(oAuthDialog) + + return oAuthDialog.Init() } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { From 4c938aa1f773150fb5119c640e9427d684a1015e Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 14:43:31 -0300 Subject: [PATCH 06/10] fix: address "verifying..." now showing on side of spinner --- internal/ui/dialog/oauth.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 1268d8a2324fff56843e8468cb2d4d3cd66895af..4a97dc21cf6af06f916fe208f237f0f911576513 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -267,10 +267,12 @@ func (m *OAuth) innerDialogContent() string { Width(m.width - 2). Render("Browser not opening? Refer to\n" + link) - waiting := greenStyle. + waiting := lipgloss.NewStyle(). + Margin(1, 1). Width(m.width - 2). - Margin(1). - Render(m.spinner.View() + "Verifying...") + Render( + greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."), + ) return lipgloss.JoinVertical( lipgloss.Left, From 6caf8788dcbaa9bf3ee2326abc738dbf428f8f5a Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 14:53:39 -0300 Subject: [PATCH 07/10] fix: align "authentication successful" on the left Co-authored-by: Christian Rocha --- internal/ui/dialog/oauth.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 4a97dc21cf6af06f916fe208f237f0f911576513..5d4a16cc409ab8d0515381cf7cbca41ae332c5cc 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -286,7 +286,6 @@ func (m *OAuth) innerDialogContent() string { return greenStyle. Margin(1). Width(m.width - 2). - Align(lipgloss.Center). Render("Authentication successful!") case OAuthStateError: From f27f5460223ea98f91ef41b826218d1de882c3e8 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 14:58:23 -0300 Subject: [PATCH 08/10] fix: address double vertical margins between sections Co-authored-by: Christian Rocha --- internal/ui/dialog/oauth.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 5d4a16cc409ab8d0515381cf7cbca41ae332c5cc..96ea4ee81cf35627926fe920b5ca2680ef9b67af 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -240,7 +240,7 @@ func (m *OAuth) innerDialogContent() string { case OAuthStateDisplay: instructions := lipgloss.NewStyle(). - Margin(1). + Margin(0, 1). Width(m.width - 2). Render( whiteStyle.Render("Press ") + @@ -253,7 +253,7 @@ func (m *OAuth) innerDialogContent() string { Height(7). Align(lipgloss.Center, lipgloss.Center). Background(t.BgBaseLighter). - Margin(1). + Margin(0, 1). Render( lipgloss.NewStyle(). Bold(true). @@ -268,7 +268,7 @@ func (m *OAuth) innerDialogContent() string { Render("Browser not opening? Refer to\n" + link) waiting := lipgloss.NewStyle(). - Margin(1, 1). + Margin(0, 1). Width(m.width - 2). Render( greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."), @@ -276,10 +276,15 @@ func (m *OAuth) innerDialogContent() string { return lipgloss.JoinVertical( lipgloss.Left, + "", instructions, + "", codeBox, + "", url, + "", waiting, + "", ) case OAuthStateSuccess: From 9f7be9d2924780b7b10d70061d46d2b9ff2bc997 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 15:06:39 -0300 Subject: [PATCH 09/10] refactor: remove init in favor of returning cmd on new Co-authored-by: Ayman Bagabas --- internal/ui/dialog/oauth.go | 9 ++------- internal/ui/dialog/oauth_copilot.go | 2 +- internal/ui/dialog/oauth_hyper.go | 2 +- internal/ui/model/ui.go | 16 ++++------------ 4 files changed, 8 insertions(+), 21 deletions(-) diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 96ea4ee81cf35627926fe920b5ca2680ef9b67af..ae5a2ab25a1ec596ba50ea6b3a0d03f560f1b10d 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -71,7 +71,7 @@ type OAuth struct { 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, error) { +func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, tea.Cmd) { t := com.Styles m := OAuth{} @@ -101,7 +101,7 @@ func newOAuth(com *common.Common, provider catwalk.Provider, model config.Select ) m.keyMap.Close = CloseKey - return &m, nil + return &m, tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth) } // ID implements Dialog. @@ -109,11 +109,6 @@ func (m *OAuth) ID() string { return OAuthID } -// Init implements Dialog. -func (m *OAuth) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth) -} - // HandleMsg handles messages and state transitions. func (m *OAuth) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go index 2364c02804ffc0eca776b24849bdb38aacf1df94..19e389b38a965c4c22ba1b2080b029975aaedc19 100644 --- a/internal/ui/dialog/oauth_copilot.go +++ b/internal/ui/dialog/oauth_copilot.go @@ -12,7 +12,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" ) -func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) { +func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) { return newOAuth(com, provider, model, modelType, &OAuthCopilot{}) } diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go index 65d3e34e817384e8e123e4720f7cdd310213aa87..478960b0df10f62d88b65450de360f4db6d6cd0c 100644 --- a/internal/ui/dialog/oauth_hyper.go +++ b/internal/ui/dialog/oauth_hyper.go @@ -12,7 +12,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" ) -func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, error) { +func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) { return newOAuth(com, provider, model, modelType, &OAuthHyper{}) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a79598bd857688491b57c58d85244e2499408498..4d3ecafe07eeb3a88c4b88f90bc60b0fe4d8266e 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1058,13 +1058,9 @@ func (m *UI) openOAuthHyperDialog(provider catwalk.Provider, model config.Select return nil } - oAuthDialog, err := dialog.NewOAuthHyper(m.com, provider, model, modelType) - if err != nil { - return uiutil.ReportError(err) - } + oAuthDialog, cmd := dialog.NewOAuthHyper(m.com, provider, model, modelType) m.dialog.OpenDialog(oAuthDialog) - - return oAuthDialog.Init() + return cmd } func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { @@ -1073,13 +1069,9 @@ func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.Sele return nil } - oAuthDialog, err := dialog.NewOAuthCopilot(m.com, provider, model, modelType) - if err != nil { - return uiutil.ReportError(err) - } + oAuthDialog, cmd := dialog.NewOAuthCopilot(m.com, provider, model, modelType) m.dialog.OpenDialog(oAuthDialog) - - return oAuthDialog.Init() + return cmd } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { From 8b5cc439a99cdf71bc8c66b62d4ed4471260585b Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 15 Jan 2026 16:53:53 -0300 Subject: [PATCH 10/10] refactor: remove duplication on functions to open dialogs Co-authored-by: Ayman Bagabas --- internal/ui/dialog/api_key_input.go | 2 +- internal/ui/model/ui.go | 45 +++++++---------------------- 2 files changed, 12 insertions(+), 35 deletions(-) diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index e28dea2b823143d176d796c8775e8024df61d0bb..430f7b4629294faa83bad9b5b90ca363ceb6f1b7 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/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{} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 4d3ecafe07eeb3a88c4b88f90bc60b0fe4d8266e..de4b0e91fc38ec280ad9d9249ab17e88dedcf748 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1028,49 +1028,26 @@ func substituteArgs(content string, args map[string]string) string { } 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": - return m.openOAuthHyperDialog(provider, model, modelType) + dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType) case catwalk.InferenceProviderCopilot: - return m.openOAuthCopilotDialog(provider, model, modelType) + dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType) default: - return m.openAPIKeyInputDialog(provider, model, modelType) - } -} - -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 - } - - apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType) - if err != nil { - return uiutil.ReportError(err) + dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType) } - m.dialog.OpenDialog(apiKeyInputDialog) - return nil -} - -func (m *UI) openOAuthHyperDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { - if m.dialog.ContainsDialog(dialog.OAuthID) { - m.dialog.BringToFront(dialog.OAuthID) - return nil - } - - oAuthDialog, cmd := dialog.NewOAuthHyper(m.com, provider, model, modelType) - m.dialog.OpenDialog(oAuthDialog) - return cmd -} -func (m *UI) openOAuthCopilotDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { - if m.dialog.ContainsDialog(dialog.OAuthID) { - m.dialog.BringToFront(dialog.OAuthID) + if m.dialog.ContainsDialog(dlg.ID()) { + m.dialog.BringToFront(dlg.ID()) return nil } - oAuthDialog, cmd := dialog.NewOAuthCopilot(m.com, provider, model, modelType) - m.dialog.OpenDialog(oAuthDialog) + m.dialog.OpenDialog(dlg) return cmd }