@@ -137,6 +137,13 @@ type (
137 // closeDialogMsg is sent to close the current dialog.
137 // closeDialogMsg is sent to close the current dialog.
138 closeDialogMsg struct{}
138 closeDialogMsg struct{}
140+ // hyperRefreshDoneMsg is sent after a silent Hyper OAuth refresh
141+ // finishes. It carries the original model-selection action so the
142+ // selection can be resumed.
143+ hyperRefreshDoneMsg struct {
144+ action dialog.ActionSelectModel
140 // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
147 // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
141 copyChatHighlightMsg struct{}
148 copyChatHighlightMsg struct{}
@@ -843,6 +850,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
843 m.textarea.SetValue(msg.Text)
850 m.textarea.SetValue(msg.Text)
844 m.textarea.MoveToEnd()
851 m.textarea.MoveToEnd()
845 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
852 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
853+ case hyperRefreshDoneMsg:
854+ if cmd := m.handleSelectModel(msg.action); cmd != nil {
855+ cmds = append(cmds, cmd)
846 case util.InfoMsg:
857 case util.InfoMsg:
847 if msg.Type == util.InfoTypeError {
858 if msg.Type == util.InfoTypeError {
848 slog.Error("Error reported", "error", msg.Msg)
859 slog.Error("Error reported", "error", msg.Msg)
@@ -1429,73 +1440,8 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1429 m.dialog.CloseDialog(dialog.CommandsID)
1440 m.dialog.CloseDialog(dialog.CommandsID)
1431 case dialog.ActionSelectModel:
1442 case dialog.ActionSelectModel:
1432− if m.isAgentBusy() {
1443+ if cmd := m.handleSelectModel(msg); cmd != nil {
1433− cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1437− cfg := m.com.Config()
1439− cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1444− providerID = msg.Model.Provider
1445− isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1446− isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1449− // Attempt to import GitHub Copilot tokens from VSCode if available.
1450− if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1451− m.com.Workspace.ImportCopilot()
1454− if !isConfigured() || msg.ReAuthenticate {
1455− m.dialog.CloseDialog(dialog.ModelsID)
1456− if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1457− cmds = append(cmds, cmd)
1444+ cmds = append(cmds, cmd)
1462− if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1463− cmds = append(cmds, util.ReportError(err))
1465− if msg.ModelType == config.SelectedModelTypeLarge {
1466− // Swap the theme live based on the newly selected large
1467− // model's provider.
1468− m.applyTheme(styles.ThemeForProvider(providerID))
1470− if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1471− // Ensure small model is set is unset.
1472− smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1473− if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1474− cmds = append(cmds, util.ReportError(err))
1479− cmds = append(cmds, func() tea.Msg {
1480− if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1481− return util.ReportError(err)
1484− modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1486− return util.NewInfoMsg(modelMsg)
1489− m.dialog.CloseDialog(dialog.APIKeyInputID)
1490− m.dialog.CloseDialog(dialog.OAuthID)
1491− m.dialog.CloseDialog(dialog.ModelsID)
1494− m.setState(uiLanding, uiFocusEditor)
1495− m.com.Config().SetupAgents()
1496− if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1497− cmds = append(cmds, util.ReportError(err))
1500 case dialog.ActionSelectReasoningEffort:
1446 case dialog.ActionSelectReasoningEffort:
1501 if m.isAgentBusy() {
1447 if m.isAgentBusy() {
@@ -1601,6 +1547,108 @@ func substituteArgs(content string, args map[string]string) string {
1601 return content
1547 return content
1550+// refreshHyperAndRetrySelect returns a command that silently refreshes
1551+// the Hyper OAuth token and then re-runs the model selection. If the
1552+// refresh fails, the selection resumes with ReAuthenticate set so the
1553+// OAuth dialog opens.
1554+func (m *UI) refreshHyperAndRetrySelect(msg dialog.ActionSelectModel) tea.Cmd {
1555+ return func() tea.Msg {
1556+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
1558+ if err := m.com.Workspace.RefreshOAuthToken(ctx, config.ScopeGlobal, "hyper"); err != nil {
1559+ slog.Warn("Hyper OAuth refresh failed, requesting re-auth", "error", err)
1560+ msg.ReAuthenticate = true
1562+ return hyperRefreshDoneMsg{action: msg}
1566+// handleSelectModel performs the model selection after any provider
1567+// pre-checks (such as a silent Hyper OAuth refresh) have completed.
1568+func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
1571+ if m.isAgentBusy() {
1572+ return util.ReportWarn("Agent is busy, please wait...")
1575+ cfg := m.com.Config()
1577+ return util.ReportError(errors.New("configuration not found"))
1581+ providerID = msg.Model.Provider
1582+ isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1583+ isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1584+ isOnboarding = m.state == uiOnboarding
1587+ // For Hyper, if the stored OAuth token is expired, try a silent
1588+ // refresh before deciding whether the provider is configured. Keeps
1589+ // users from hitting a 401 on their first message after the
1590+ // short-lived access token ages out.
1591+ if !msg.ReAuthenticate && providerID == "hyper" {
1592+ if pc, ok := cfg.Providers.Get(providerID); ok && pc.OAuthToken != nil && pc.OAuthToken.IsExpired() {
1593+ return m.refreshHyperAndRetrySelect(msg)
1597+ // Attempt to import GitHub Copilot tokens from VSCode if available.
1598+ if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1599+ m.com.Workspace.ImportCopilot()
1602+ if !isConfigured() || msg.ReAuthenticate {
1603+ m.dialog.CloseDialog(dialog.ModelsID)
1604+ if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1605+ cmds = append(cmds, cmd)
1607+ return tea.Batch(cmds...)
1610+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1611+ cmds = append(cmds, util.ReportError(err))
1613+ if msg.ModelType == config.SelectedModelTypeLarge {
1614+ // Swap the theme live based on the newly selected large
1615+ // model's provider.
1616+ m.applyTheme(styles.ThemeForProvider(providerID))
1618+ if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1619+ // Ensure small model is set is unset.
1620+ smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1621+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1622+ cmds = append(cmds, util.ReportError(err))
1627+ cmds = append(cmds, func() tea.Msg {
1628+ if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1629+ return util.ReportError(err)
1632+ modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1634+ return util.NewInfoMsg(modelMsg)
1637+ m.dialog.CloseDialog(dialog.APIKeyInputID)
1638+ m.dialog.CloseDialog(dialog.OAuthID)
1639+ m.dialog.CloseDialog(dialog.ModelsID)
1642+ m.setState(uiLanding, uiFocusEditor)
1643+ m.com.Config().SetupAgents()
1644+ if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1645+ cmds = append(cmds, util.ReportError(err))
1649+ return tea.Batch(cmds...)
1604func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1652func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1606 dlg dialog.Dialog
1654 dlg dialog.Dialog