diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 5cec262b8fc8cdbd5619f05c764056ecff0d926e..46d96e9c358091075d82cfd4eb260f3c48fa0dd6 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -137,6 +137,13 @@ type ( // closeDialogMsg is sent to close the current dialog. closeDialogMsg struct{} + // hyperRefreshDoneMsg is sent after a silent Hyper OAuth refresh + // finishes. It carries the original model-selection action so the + // selection can be resumed. + hyperRefreshDoneMsg struct { + action dialog.ActionSelectModel + } + // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard. copyChatHighlightMsg struct{} @@ -843,6 +850,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textarea.SetValue(msg.Text) m.textarea.MoveToEnd() cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight)) + case hyperRefreshDoneMsg: + if cmd := m.handleSelectModel(msg.action); cmd != nil { + cmds = append(cmds, cmd) + } case util.InfoMsg: if msg.Type == util.InfoTypeError { slog.Error("Error reported", "error", msg.Msg) @@ -1429,73 +1440,8 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionSelectModel: - if m.isAgentBusy() { - cmds = append(cmds, util.ReportWarn("Agent is busy, please wait...")) - break - } - - cfg := m.com.Config() - if cfg == nil { - cmds = append(cmds, util.ReportError(errors.New("configuration not found"))) - break - } - - 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() && !msg.ReAuthenticate { - m.com.Workspace.ImportCopilot() - } - - if !isConfigured() || msg.ReAuthenticate { - m.dialog.CloseDialog(dialog.ModelsID) - if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { - cmds = append(cmds, cmd) - } - break - } - - if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil { - cmds = append(cmds, util.ReportError(err)) - } else { - if msg.ModelType == config.SelectedModelTypeLarge { - // Swap the theme live based on the newly selected large - // model's provider. - m.applyTheme(styles.ThemeForProvider(providerID)) - } - if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok { - // Ensure small model is set is unset. - smallModel := m.com.Workspace.GetDefaultSmallModel(providerID) - if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil { - cmds = append(cmds, util.ReportError(err)) - } - } - } - - cmds = append(cmds, func() tea.Msg { - if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil { - return util.ReportError(err) - } - - modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) - - return util.NewInfoMsg(modelMsg) - }) - - m.dialog.CloseDialog(dialog.APIKeyInputID) - m.dialog.CloseDialog(dialog.OAuthID) - m.dialog.CloseDialog(dialog.ModelsID) - - if isOnboarding { - m.setState(uiLanding, uiFocusEditor) - m.com.Config().SetupAgents() - if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil { - cmds = append(cmds, util.ReportError(err)) - } + if cmd := m.handleSelectModel(msg); cmd != nil { + cmds = append(cmds, cmd) } case dialog.ActionSelectReasoningEffort: if m.isAgentBusy() { @@ -1601,6 +1547,108 @@ func substituteArgs(content string, args map[string]string) string { return content } +// refreshHyperAndRetrySelect returns a command that silently refreshes +// the Hyper OAuth token and then re-runs the model selection. If the +// refresh fails, the selection resumes with ReAuthenticate set so the +// OAuth dialog opens. +func (m *UI) refreshHyperAndRetrySelect(msg dialog.ActionSelectModel) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := m.com.Workspace.RefreshOAuthToken(ctx, config.ScopeGlobal, "hyper"); err != nil { + slog.Warn("Hyper OAuth refresh failed, requesting re-auth", "error", err) + msg.ReAuthenticate = true + } + return hyperRefreshDoneMsg{action: msg} + } +} + +// handleSelectModel performs the model selection after any provider +// pre-checks (such as a silent Hyper OAuth refresh) have completed. +func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd { + var cmds []tea.Cmd + + if m.isAgentBusy() { + return util.ReportWarn("Agent is busy, please wait...") + } + + cfg := m.com.Config() + if cfg == nil { + return util.ReportError(errors.New("configuration not found")) + } + + var ( + providerID = msg.Model.Provider + isCopilot = providerID == string(catwalk.InferenceProviderCopilot) + isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok } + isOnboarding = m.state == uiOnboarding + ) + + // For Hyper, if the stored OAuth token is expired, try a silent + // refresh before deciding whether the provider is configured. Keeps + // users from hitting a 401 on their first message after the + // short-lived access token ages out. + if !msg.ReAuthenticate && providerID == "hyper" { + if pc, ok := cfg.Providers.Get(providerID); ok && pc.OAuthToken != nil && pc.OAuthToken.IsExpired() { + return m.refreshHyperAndRetrySelect(msg) + } + } + + // Attempt to import GitHub Copilot tokens from VSCode if available. + if isCopilot && !isConfigured() && !msg.ReAuthenticate { + m.com.Workspace.ImportCopilot() + } + + if !isConfigured() || msg.ReAuthenticate { + m.dialog.CloseDialog(dialog.ModelsID) + if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { + cmds = append(cmds, cmd) + } + return tea.Batch(cmds...) + } + + if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil { + cmds = append(cmds, util.ReportError(err)) + } else { + if msg.ModelType == config.SelectedModelTypeLarge { + // Swap the theme live based on the newly selected large + // model's provider. + m.applyTheme(styles.ThemeForProvider(providerID)) + } + if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok { + // Ensure small model is set is unset. + smallModel := m.com.Workspace.GetDefaultSmallModel(providerID) + if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil { + cmds = append(cmds, util.ReportError(err)) + } + } + } + + cmds = append(cmds, func() tea.Msg { + if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil { + return util.ReportError(err) + } + + modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) + + return util.NewInfoMsg(modelMsg) + }) + + m.dialog.CloseDialog(dialog.APIKeyInputID) + m.dialog.CloseDialog(dialog.OAuthID) + m.dialog.CloseDialog(dialog.ModelsID) + + if isOnboarding { + m.setState(uiLanding, uiFocusEditor) + m.com.Config().SetupAgents() + if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil { + cmds = append(cmds, util.ReportError(err)) + } + } + + return tea.Batch(cmds...) +} + func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { var ( dlg dialog.Dialog